administratum 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/DESIGN.md +68 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +431 -0
  5. data/Rakefile +6 -0
  6. data/app/admin/administratum/user_admin.rb +39 -0
  7. data/app/assets/builds/administratum.css +2 -0
  8. data/app/assets/javascripts/administratum.js +250 -0
  9. data/app/assets/tailwind/administratum/components.css +199 -0
  10. data/app/assets/tailwind/administratum/index.css +8 -0
  11. data/app/assets/tailwind/administratum/theme.css +62 -0
  12. data/app/controllers/administratum/application_controller.rb +31 -0
  13. data/app/controllers/administratum/dashboard_controller.rb +7 -0
  14. data/app/controllers/administratum/page.rb +51 -0
  15. data/app/controllers/administratum/password_resets_controller.rb +42 -0
  16. data/app/controllers/administratum/resource.rb +224 -0
  17. data/app/controllers/administratum/sessions_controller.rb +33 -0
  18. data/app/helpers/administratum/application_helper.rb +11 -0
  19. data/app/helpers/administratum/form_helper.rb +115 -0
  20. data/app/helpers/administratum/island_helper.rb +16 -0
  21. data/app/helpers/administratum/layout_helper.rb +81 -0
  22. data/app/helpers/administratum/resource_helper.rb +199 -0
  23. data/app/mailers/administratum/application_mailer.rb +8 -0
  24. data/app/mailers/administratum/user_mailer.rb +11 -0
  25. data/app/models/administratum/application_record.rb +7 -0
  26. data/app/models/administratum/user.rb +13 -0
  27. data/app/views/administratum/dashboard/index.html.erb +16 -0
  28. data/app/views/administratum/fields/_belongs_to.html.erb +1 -0
  29. data/app/views/administratum/fields/_boolean.html.erb +1 -0
  30. data/app/views/administratum/fields/_date.html.erb +1 -0
  31. data/app/views/administratum/fields/_datetime.html.erb +1 -0
  32. data/app/views/administratum/fields/_email.html.erb +1 -0
  33. data/app/views/administratum/fields/_has_many_ids.html.erb +1 -0
  34. data/app/views/administratum/fields/_hidden.html.erb +1 -0
  35. data/app/views/administratum/fields/_island.html.erb +4 -0
  36. data/app/views/administratum/fields/_json.html.erb +1 -0
  37. data/app/views/administratum/fields/_multi_select.html.erb +1 -0
  38. data/app/views/administratum/fields/_number.html.erb +1 -0
  39. data/app/views/administratum/fields/_password.html.erb +1 -0
  40. data/app/views/administratum/fields/_select.html.erb +1 -0
  41. data/app/views/administratum/fields/_string.html.erb +1 -0
  42. data/app/views/administratum/fields/_textarea.html.erb +1 -0
  43. data/app/views/administratum/fields/_wrapper.html.erb +11 -0
  44. data/app/views/administratum/pages/index.html.erb +7 -0
  45. data/app/views/administratum/password_resets/edit.html.erb +17 -0
  46. data/app/views/administratum/password_resets/new.html.erb +11 -0
  47. data/app/views/administratum/resources/_attributes.html.erb +11 -0
  48. data/app/views/administratum/resources/_form.html.erb +14 -0
  49. data/app/views/administratum/resources/_table.html.erb +42 -0
  50. data/app/views/administratum/resources/edit.html.erb +7 -0
  51. data/app/views/administratum/resources/index.html.erb +17 -0
  52. data/app/views/administratum/resources/new.html.erb +7 -0
  53. data/app/views/administratum/resources/show.html.erb +7 -0
  54. data/app/views/administratum/sessions/new.html.erb +18 -0
  55. data/app/views/administratum/shared/_card.html.erb +9 -0
  56. data/app/views/administratum/shared/_filters.html.erb +43 -0
  57. data/app/views/administratum/shared/_flash.html.erb +9 -0
  58. data/app/views/administratum/shared/_form_errors.html.erb +10 -0
  59. data/app/views/administratum/shared/_nav.html.erb +10 -0
  60. data/app/views/administratum/shared/_page_header.html.erb +17 -0
  61. data/app/views/administratum/shared/_panel.html.erb +24 -0
  62. data/app/views/administratum/shared/_topbar.html.erb +29 -0
  63. data/app/views/administratum/user_mailer/reset_password.html.erb +4 -0
  64. data/app/views/administratum/user_mailer/reset_password.text.erb +7 -0
  65. data/app/views/layouts/administratum/application.html.erb +25 -0
  66. data/app/views/layouts/administratum/authentication.html.erb +32 -0
  67. data/config/locales/en.yml +65 -0
  68. data/config/routes.rb +16 -0
  69. data/db/migrate/20261016000000_create_administratum_users.rb +17 -0
  70. data/lib/administratum/configuration.rb +66 -0
  71. data/lib/administratum/definition.rb +283 -0
  72. data/lib/administratum/dsl.rb +195 -0
  73. data/lib/administratum/engine.rb +31 -0
  74. data/lib/administratum/field_types.rb +77 -0
  75. data/lib/administratum/page_dsl.rb +48 -0
  76. data/lib/administratum/paths.rb +85 -0
  77. data/lib/administratum/ransackable.rb +41 -0
  78. data/lib/administratum/registry.rb +95 -0
  79. data/lib/administratum/routing.rb +57 -0
  80. data/lib/administratum/tailwind.rb +34 -0
  81. data/lib/administratum/version.rb +5 -0
  82. data/lib/administratum.rb +43 -0
  83. data/lib/generators/administratum/install/install_generator.rb +42 -0
  84. data/lib/generators/administratum/install/templates/administratum.rb +41 -0
  85. data/lib/generators/administratum/resource/resource_generator.rb +30 -0
  86. data/lib/generators/administratum/resource/templates/resource.rb.tt +20 -0
  87. data/lib/tasks/administratum.rake +32 -0
  88. metadata +209 -0
@@ -0,0 +1,250 @@
1
+ // Administratum runtime: islands, offcanvas panels, dropdowns, confirmations,
2
+ // flash messages and the light/dark toggle. Plain ES module, no dependencies.
3
+ // Behaviour is attached with event delegation, so it survives Turbo page swaps.
4
+
5
+ function install() {
6
+ const ISLAND_SELECTOR = "[data-administratum-island]"
7
+ const THEME_KEY = "administratum-theme"
8
+
9
+ // ---------------------------------------------------------------- islands --
10
+
11
+ const islands = new Map()
12
+ const mounted = new Map()
13
+
14
+ function readProps(el) {
15
+ const script = el.querySelector(':scope > script[type="application/json"]')
16
+ if (!script) return {}
17
+ try {
18
+ return JSON.parse(script.textContent || "{}")
19
+ } catch (error) {
20
+ console.error(`[administratum] invalid props for island "${el.dataset.administratumIsland}"`, error)
21
+ return {}
22
+ }
23
+ }
24
+
25
+ function mountIslands(root = document) {
26
+ root.querySelectorAll(ISLAND_SELECTOR).forEach((el) => {
27
+ if (mounted.has(el)) return
28
+ const mount = islands.get(el.dataset.administratumIsland)
29
+ if (!mount) return
30
+
31
+ const props = readProps(el)
32
+ const teardown = mount(el, props)
33
+ mounted.set(el, typeof teardown === "function" ? teardown : null)
34
+ el.dataset.administratumMounted = "true"
35
+ })
36
+ }
37
+
38
+ function unmountIslands() {
39
+ mounted.forEach((teardown, el) => {
40
+ try {
41
+ if (teardown) teardown()
42
+ } catch (error) {
43
+ console.error("[administratum] island teardown failed", error)
44
+ }
45
+ delete el.dataset.administratumMounted
46
+ })
47
+ mounted.clear()
48
+ }
49
+
50
+ // register("chapter-editor", (el, props) => { ...; return () => cleanup() })
51
+ function register(name, mount) {
52
+ islands.set(name, mount)
53
+ if (document.readyState !== "loading") mountIslands()
54
+ }
55
+
56
+ // --------------------------------------------------------------- offcanvas --
57
+
58
+ let lastTrigger = null
59
+
60
+ function openPanel(name, trigger) {
61
+ const panel = document.querySelector(`[data-adm-offcanvas="${CSS.escape(name)}"]`)
62
+ if (!panel) return
63
+ lastTrigger = trigger || null
64
+ panel.hidden = false
65
+ document.documentElement.classList.add("adm-scroll-lock")
66
+ requestAnimationFrame(() => {
67
+ panel.classList.add("adm-offcanvas-open")
68
+ const focusable = panel.querySelector("input:not([type=hidden]), select, textarea, button:not([data-adm-offcanvas-close])")
69
+ if (focusable) focusable.focus()
70
+ })
71
+ }
72
+
73
+ function closePanels() {
74
+ document.querySelectorAll(".adm-offcanvas-open").forEach((panel) => {
75
+ panel.classList.remove("adm-offcanvas-open")
76
+ panel.hidden = true
77
+ })
78
+ document.documentElement.classList.remove("adm-scroll-lock")
79
+ if (lastTrigger) lastTrigger.focus()
80
+ lastTrigger = null
81
+ }
82
+
83
+ // --------------------------------------------------------------- dropdowns --
84
+
85
+ function closeDropdowns(except) {
86
+ document.querySelectorAll("[data-adm-dropdown]").forEach((dropdown) => {
87
+ if (dropdown === except) return
88
+ const menu = dropdown.querySelector("[data-adm-dropdown-menu]")
89
+ const toggle = dropdown.querySelector("[data-adm-dropdown-toggle]")
90
+ if (menu) menu.hidden = true
91
+ if (toggle) toggle.setAttribute("aria-expanded", "false")
92
+ })
93
+ }
94
+
95
+ // ------------------------------------------------------------------- theme --
96
+
97
+ const darkQuery = window.matchMedia("(prefers-color-scheme: dark)")
98
+
99
+ function storedTheme() {
100
+ try {
101
+ const value = localStorage.getItem(THEME_KEY)
102
+ return value === "dark" || value === "light" ? value : null
103
+ } catch (_error) {
104
+ return null
105
+ }
106
+ }
107
+
108
+ function applyTheme(theme) {
109
+ const root = document.documentElement
110
+ root.classList.remove("dark", "light")
111
+ root.classList.add(theme)
112
+ }
113
+
114
+ function currentTheme() {
115
+ return document.documentElement.classList.contains("dark") ? "dark" : "light"
116
+ }
117
+
118
+ function toggleTheme() {
119
+ const next = currentTheme() === "dark" ? "light" : "dark"
120
+ applyTheme(next)
121
+ try {
122
+ localStorage.setItem(THEME_KEY, next)
123
+ } catch (_error) {
124
+ // Storage unavailable (private mode); the choice lasts for this page only.
125
+ }
126
+ }
127
+
128
+ // Follow the OS setting until the viewer picks a theme themselves.
129
+ darkQuery.addEventListener("change", (event) => {
130
+ if (!storedTheme()) applyTheme(event.matches ? "dark" : "light")
131
+ })
132
+
133
+ // ------------------------------------------------------------------- flash --
134
+
135
+ function scheduleFlashDismissal(root = document) {
136
+ root.querySelectorAll("[data-adm-flash]:not([data-adm-flash-scheduled])").forEach((el) => {
137
+ el.dataset.admFlashScheduled = "true"
138
+ setTimeout(() => el.remove(), 6000)
139
+ })
140
+ }
141
+
142
+ // ------------------------------------------------------------------ events --
143
+
144
+ document.addEventListener("click", (event) => {
145
+ const target = event.target instanceof Element ? event.target : null
146
+ if (!target) return
147
+
148
+ const opener = target.closest("[data-adm-offcanvas-open]")
149
+ if (opener) {
150
+ event.preventDefault()
151
+ openPanel(opener.dataset.admOffcanvasOpen, opener)
152
+ return
153
+ }
154
+
155
+ if (target.closest("[data-adm-offcanvas-close]")) {
156
+ event.preventDefault()
157
+ closePanels()
158
+ return
159
+ }
160
+
161
+ const dropdownToggle = target.closest("[data-adm-dropdown-toggle]")
162
+ if (dropdownToggle) {
163
+ const dropdown = dropdownToggle.closest("[data-adm-dropdown]")
164
+ const menu = dropdown.querySelector("[data-adm-dropdown-menu]")
165
+ const willOpen = menu.hidden
166
+ closeDropdowns(dropdown)
167
+ menu.hidden = !willOpen
168
+ dropdownToggle.setAttribute("aria-expanded", String(willOpen))
169
+ return
170
+ }
171
+ if (!target.closest("[data-adm-dropdown]")) closeDropdowns()
172
+
173
+ const toggle = target.closest("[data-adm-toggle]")
174
+ if (toggle) {
175
+ const el = document.getElementById(toggle.dataset.admToggle)
176
+ if (el) {
177
+ const open = el.classList.toggle("adm-open")
178
+ toggle.setAttribute("aria-expanded", String(open))
179
+ }
180
+ return
181
+ }
182
+
183
+ if (target.closest("[data-adm-theme-toggle]")) {
184
+ toggleTheme()
185
+ return
186
+ }
187
+
188
+ const dismiss = target.closest("[data-adm-dismiss]")
189
+ if (dismiss) dismiss.parentElement.remove()
190
+ })
191
+
192
+ document.addEventListener("keydown", (event) => {
193
+ if (event.key !== "Escape") return
194
+ closePanels()
195
+ closeDropdowns()
196
+ })
197
+
198
+ // Forms with data-adm-confirm ask before submitting (button_to delete etc.).
199
+ document.addEventListener("submit", (event) => {
200
+ const form = event.target
201
+ if (!(form instanceof HTMLFormElement)) return
202
+ const message = form.dataset.admConfirm
203
+ if (message && !window.confirm(message)) {
204
+ event.preventDefault()
205
+ event.stopImmediatePropagation()
206
+ }
207
+ }, true)
208
+
209
+ function boot() {
210
+ mountIslands()
211
+ scheduleFlashDismissal()
212
+ }
213
+
214
+ document.addEventListener("DOMContentLoaded", boot)
215
+ document.addEventListener("turbo:load", boot)
216
+ document.addEventListener("turbo:before-render", () => {
217
+ closePanels()
218
+ unmountIslands()
219
+ })
220
+ if (document.readyState !== "loading") boot()
221
+
222
+ // ------------------------------------------------------------------ public --
223
+
224
+ const Administratum = window.Administratum || {}
225
+ Administratum.loaded = true
226
+ const queued = (Administratum.islands && Administratum.islands.queue) || []
227
+
228
+ Administratum.islands = {
229
+ register,
230
+ mount: mountIslands,
231
+ unmount: unmountIslands
232
+ }
233
+ Administratum.csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content
234
+ Administratum.openPanel = openPanel
235
+ Administratum.closePanels = closePanels
236
+
237
+ window.Administratum = Administratum
238
+
239
+ // Islands registered before this script ran:
240
+ // (window.Administratum ||= { islands: { queue: [] } }).islands.queue.push([name, fn])
241
+ queued.forEach(([name, mount]) => register(name, mount))
242
+
243
+ return Administratum
244
+ }
245
+
246
+ // Loading this file twice (e.g. a host bundle that also imports it) would bind
247
+ // every listener twice and replace the island registry, so only install once.
248
+ const Administratum = window.Administratum && window.Administratum.loaded ? window.Administratum : install()
249
+
250
+ export default Administratum
@@ -0,0 +1,199 @@
1
+ /* Administratum components. Every colour comes from a --color-adm-* token. */
2
+
3
+ @layer base {
4
+ .adm-body {
5
+ @apply min-h-screen bg-adm-bg font-adm text-sm text-adm-text antialiased;
6
+ }
7
+
8
+ .adm-scroll-lock {
9
+ @apply overflow-hidden;
10
+ }
11
+ }
12
+
13
+ @layer components {
14
+ /* Layout ---------------------------------------------------------------- */
15
+ .adm-container { @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; }
16
+ .adm-main { @apply flex flex-col pb-12; }
17
+ .adm-page-body { @apply flex flex-col gap-6 pt-6; }
18
+ .adm-grid { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-4; }
19
+ .adm-sr-only { @apply sr-only; }
20
+ .adm-muted { @apply text-adm-muted; }
21
+
22
+ /* Top bar and navigation ------------------------------------------------ */
23
+ .adm-topbar { @apply border-b border-adm-border bg-adm-surface; }
24
+ .adm-topbar-inner { @apply flex h-14 items-center gap-3; }
25
+ .adm-brand { @apply flex items-center gap-2 font-semibold text-adm-text no-underline; }
26
+ .adm-brand-name { @apply text-base tracking-tight; }
27
+ .adm-logo { @apply h-7 w-auto; }
28
+ .adm-topbar-actions { @apply ml-auto flex items-center gap-1; }
29
+
30
+ .adm-nav { @apply hidden border-b border-adm-border bg-adm-surface md:block; }
31
+ .adm-nav.adm-open { @apply block; }
32
+ .adm-nav-list { @apply -mb-px flex flex-col gap-1 py-2 md:flex-row md:gap-6 md:py-0; }
33
+ .adm-nav-link {
34
+ @apply block border-b-2 border-transparent py-3 font-medium text-adm-muted no-underline transition-colors hover:text-adm-text;
35
+ }
36
+ .adm-nav-link-active { @apply border-adm-primary text-adm-text; }
37
+
38
+ .adm-dropdown { @apply relative; }
39
+ .adm-dropdown-menu {
40
+ @apply absolute right-0 z-30 mt-2 min-w-44 overflow-hidden rounded-adm border border-adm-border bg-adm-surface py-1 shadow-lg;
41
+ }
42
+ .adm-dropdown-item {
43
+ @apply block w-full cursor-pointer px-4 py-2 text-left text-adm-text hover:bg-adm-surface-muted;
44
+ }
45
+
46
+ /* Page header ----------------------------------------------------------- */
47
+ .adm-page-header { @apply border-b border-adm-border bg-adm-surface; }
48
+ .adm-page-header-inner { @apply flex flex-wrap items-end gap-4 py-5; }
49
+ .adm-page-heading { @apply min-w-0 flex-1; }
50
+ .adm-page-title { @apply truncate text-xl font-semibold tracking-tight text-adm-text; }
51
+ .adm-page-actions { @apply flex flex-wrap items-center gap-2; }
52
+ .adm-breadcrumb { @apply mb-1 flex flex-wrap items-center gap-1.5 text-xs text-adm-muted; }
53
+ .adm-breadcrumb-item a { @apply text-adm-muted no-underline hover:text-adm-text; }
54
+ .adm-breadcrumb-item + .adm-breadcrumb-item::before { content: "/"; @apply mr-1.5 text-adm-border; }
55
+
56
+ /* Cards ----------------------------------------------------------------- */
57
+ .adm-card { @apply overflow-hidden rounded-adm border border-adm-border bg-adm-surface shadow-xs; }
58
+ .adm-card-header { @apply flex items-center justify-between gap-4 border-b border-adm-border px-5 py-3.5; }
59
+ .adm-card-title { @apply text-base font-semibold text-adm-text; }
60
+ .adm-card-body { @apply p-5; }
61
+ .adm-card-footer { @apply border-t border-adm-border bg-adm-surface-muted px-5 py-3; }
62
+ .adm-tile { @apply flex flex-col gap-1 p-5 no-underline transition-shadow hover:shadow-md; }
63
+ .adm-tile-label { @apply text-adm-muted; }
64
+ .adm-tile-value { @apply text-2xl font-semibold text-adm-text tabular-nums; }
65
+
66
+ /* Buttons --------------------------------------------------------------- */
67
+ .adm-btn {
68
+ @apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-adm border px-3.5 py-2 text-sm font-medium whitespace-nowrap no-underline transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-adm-ring disabled:cursor-not-allowed disabled:opacity-50;
69
+ }
70
+ .adm-btn-primary { @apply border-adm-primary bg-adm-primary text-adm-primary-fg hover:opacity-90; }
71
+ .adm-btn-secondary { @apply border-adm-border bg-adm-surface text-adm-text hover:bg-adm-surface-muted; }
72
+ .adm-btn-danger { @apply border-adm-border bg-adm-surface text-adm-danger hover:border-adm-danger hover:bg-adm-danger hover:text-adm-danger-fg; }
73
+ .adm-btn-ghost { @apply border-transparent bg-transparent text-adm-text hover:bg-adm-surface-muted; }
74
+ .adm-btn-sm { @apply px-2.5 py-1 text-xs; }
75
+ .adm-btn-block { @apply w-full; }
76
+ .adm-btn-group { @apply flex items-center justify-end gap-1; }
77
+ .adm-inline-form { @apply inline-flex; }
78
+ .adm-icon-btn {
79
+ @apply inline-flex size-9 cursor-pointer items-center justify-center rounded-adm text-lg text-adm-muted hover:bg-adm-surface-muted hover:text-adm-text focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-adm-ring;
80
+ }
81
+ .adm-icon { @apply size-5; }
82
+ .adm-link { @apply font-medium text-adm-primary no-underline hover:underline; }
83
+
84
+ /* Tables ---------------------------------------------------------------- */
85
+ .adm-table-wrap { @apply overflow-x-auto; }
86
+ .adm-table { @apply w-full border-collapse text-left; }
87
+ .adm-table th {
88
+ @apply border-b border-adm-border bg-adm-surface-muted px-4 py-2.5 text-xs font-semibold tracking-wide whitespace-nowrap text-adm-muted uppercase;
89
+ }
90
+ .adm-table th a { @apply text-adm-muted no-underline hover:text-adm-text; }
91
+ .adm-table td { @apply border-b border-adm-border px-4 py-3 align-middle; }
92
+ .adm-table tbody tr:last-child td { @apply border-b-0; }
93
+ .adm-table tbody tr:hover td { @apply bg-adm-surface-muted; }
94
+ .adm-table-actions { @apply w-px whitespace-nowrap text-right; }
95
+ .adm-table-footer { @apply flex flex-wrap items-center justify-between gap-3; }
96
+ .adm-empty { @apply py-10 text-center text-adm-muted; }
97
+
98
+ .adm-tabs { @apply -mb-2 flex flex-wrap gap-x-6 gap-y-1 border-b border-adm-border; }
99
+ .adm-tab {
100
+ @apply -mb-px inline-flex items-center gap-2 border-b-2 border-transparent py-2 font-medium text-adm-muted no-underline hover:text-adm-text;
101
+ }
102
+ .adm-tab-active { @apply border-adm-primary text-adm-text; }
103
+ .adm-tab-count { @apply rounded-full bg-adm-surface-muted px-2 py-0.5 text-xs text-adm-muted; }
104
+ .adm-pagy .pagy { @apply flex items-center gap-1; }
105
+ .adm-pagy .pagy a {
106
+ @apply rounded-adm px-2.5 py-1 text-adm-text no-underline hover:bg-adm-surface;
107
+ }
108
+ .adm-pagy .pagy a.current,
109
+ .adm-pagy .pagy a[aria-current="page"] { @apply bg-adm-primary text-adm-primary-fg; }
110
+ .adm-pagy .pagy a:not([href]) { @apply text-adm-muted; }
111
+
112
+ /* Attributes (show page) ------------------------------------------------ */
113
+ .adm-attributes { @apply divide-y divide-adm-border; }
114
+ .adm-attribute { @apply grid gap-1 px-5 py-3 sm:grid-cols-[12rem_1fr] sm:gap-6; }
115
+ .adm-attribute dt { @apply font-medium text-adm-muted; }
116
+ .adm-attribute dd { @apply min-w-0 break-words text-adm-text; }
117
+
118
+ /* Forms ----------------------------------------------------------------- */
119
+ .adm-form { @apply flex flex-col gap-5; }
120
+ .adm-form-flush { @apply gap-0; }
121
+ .adm-form .field_with_errors { display: contents; }
122
+ .adm-form-errors { @apply px-6 pt-6; }
123
+ .adm-form-body { @apply flex flex-col gap-6 p-6; }
124
+
125
+ /* Stacked field: label above control (custom forms, panels, sign in). */
126
+ .adm-field { @apply flex flex-col gap-2; }
127
+ /* Horizontal field: label column beside the control, one row per field. */
128
+ .adm-field-horizontal {
129
+ @apply gap-2 sm:grid sm:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] sm:gap-x-8;
130
+ }
131
+ .adm-field-label { @apply flex flex-col sm:pt-2.5; }
132
+ .adm-field-control { @apply flex w-full max-w-3xl min-w-0 flex-col gap-2; }
133
+ .adm-field-row { @apply flex gap-2; }
134
+ .adm-label { @apply font-medium text-adm-text; }
135
+ .adm-required { @apply ml-0.5 text-adm-danger; }
136
+ .adm-hint { @apply text-xs leading-relaxed text-adm-muted; }
137
+ .adm-error { @apply text-xs font-medium text-adm-danger; }
138
+ .adm-input {
139
+ @apply block min-h-10 w-full rounded-adm border border-adm-border bg-adm-surface px-3.5 py-2 text-sm text-adm-text shadow-xs transition-colors placeholder:text-adm-muted/70 focus:border-adm-primary focus:outline-none focus:ring-3 focus:ring-adm-ring;
140
+ }
141
+ textarea.adm-input { @apply min-h-24 leading-relaxed; }
142
+ select[multiple].adm-input { @apply py-1.5; }
143
+ /* Safari ignores padding and height on natively styled selects, so draw
144
+ our own: no native appearance, and a chevron made of two token-coloured
145
+ gradients (a data-URI SVG would need a hard-coded colour). */
146
+ select.adm-input:not([multiple]) {
147
+ @apply appearance-none bg-no-repeat pr-9;
148
+ background-image:
149
+ linear-gradient(45deg, transparent 50%, var(--color-adm-muted) 50%),
150
+ linear-gradient(135deg, var(--color-adm-muted) 50%, transparent 50%);
151
+ background-position: calc(100% - 1.125rem) 52%, calc(100% - 0.8125rem) 52%;
152
+ background-size: 0.3125rem 0.3125rem;
153
+ }
154
+ .adm-field-invalid .adm-input { @apply border-adm-danger; }
155
+ .adm-checkbox { @apply size-4.5 rounded accent-adm-primary; }
156
+ .adm-field-horizontal .adm-field-control > .adm-checkbox { @apply sm:mt-3; }
157
+ /* Buttons line up with the control column of horizontal fields. */
158
+ .adm-form-actions {
159
+ @apply flex flex-wrap items-center gap-2 border-t border-adm-border bg-adm-surface-muted px-6 py-4 sm:pl-[16.5rem];
160
+ }
161
+ .adm-check { @apply flex items-center gap-2 text-adm-text; }
162
+ .adm-code { @apply font-adm-mono text-xs; }
163
+ .adm-form-footer { @apply flex flex-wrap items-center gap-2; }
164
+
165
+ .adm-alert { @apply rounded-adm border border-adm-danger/40 bg-adm-danger/10 px-4 py-3 text-adm-text; }
166
+ .adm-alert-title { @apply mb-1 font-semibold text-adm-danger; }
167
+ .adm-alert ul { @apply list-disc pl-5; }
168
+
169
+ .adm-badge { @apply inline-flex items-center rounded-full bg-adm-surface-muted px-2 py-0.5 text-xs font-medium text-adm-muted; }
170
+ .adm-badge-success { @apply bg-adm-success/15 text-adm-success; }
171
+
172
+ /* Offcanvas panels ------------------------------------------------------ */
173
+ .adm-offcanvas { @apply fixed inset-0 z-40; }
174
+ .adm-offcanvas-backdrop { @apply absolute inset-0 bg-adm-overlay opacity-0 transition-opacity duration-200; }
175
+ .adm-offcanvas-panel {
176
+ @apply absolute inset-y-0 right-0 flex w-full max-w-sm translate-x-full flex-col bg-adm-surface shadow-xl transition-transform duration-200;
177
+ }
178
+ .adm-offcanvas-open .adm-offcanvas-backdrop { @apply opacity-100; }
179
+ .adm-offcanvas-open .adm-offcanvas-panel { @apply translate-x-0; }
180
+ .adm-offcanvas-header { @apply flex items-center justify-between border-b border-adm-border px-5 py-3.5; }
181
+ .adm-offcanvas-body { @apply flex-1 overflow-y-auto p-5; }
182
+
183
+ /* Flash ----------------------------------------------------------------- */
184
+ .adm-flash-stack { @apply pointer-events-none fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4; }
185
+ .adm-flash {
186
+ @apply pointer-events-auto flex max-w-lg items-center gap-3 rounded-adm border px-4 py-2.5 shadow-lg;
187
+ }
188
+ .adm-flash-notice { @apply border-adm-border bg-adm-text text-adm-surface; }
189
+ .adm-flash-alert { @apply border-adm-danger bg-adm-danger text-adm-danger-fg; }
190
+ .adm-flash-close { @apply cursor-pointer text-lg leading-none opacity-70 hover:opacity-100; }
191
+
192
+ /* Authentication -------------------------------------------------------- */
193
+ .adm-auth { @apply flex items-center justify-center px-4 py-16; }
194
+ .adm-auth-panel { @apply flex w-full max-w-sm flex-col gap-6; }
195
+ .adm-auth-brand { @apply text-center text-xl font-semibold text-adm-text; }
196
+ .adm-auth-title { @apply mb-4 text-lg font-semibold text-adm-text; }
197
+ .adm-auth-lead { @apply mb-4; }
198
+ .adm-auth-footer { @apply mt-4 text-center; }
199
+ }
@@ -0,0 +1,8 @@
1
+ /* Entry point for the bundled stylesheet (bin/build-css). */
2
+ @import "tailwindcss" source(none);
3
+ @import "./theme.css";
4
+ @import "./components.css";
5
+
6
+ @source "../../../views";
7
+ @source "../../../helpers";
8
+ @source "../../javascripts";
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Administratum design tokens. Override any of them in your own @theme (host
3
+ * Tailwind mode), with config.theme in the initializer, or with plain CSS on
4
+ * :root. Components only ever reference these variables.
5
+ */
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
8
+ @theme {
9
+ --color-adm-bg: oklch(0.975 0.004 255);
10
+ --color-adm-surface: oklch(1 0 0);
11
+ --color-adm-surface-muted: oklch(0.965 0.006 255);
12
+ --color-adm-border: oklch(0.91 0.008 255);
13
+ --color-adm-text: oklch(0.24 0.02 260);
14
+ --color-adm-muted: oklch(0.52 0.02 260);
15
+ --color-adm-primary: oklch(0.52 0.18 262);
16
+ --color-adm-primary-fg: oklch(0.99 0 0);
17
+ --color-adm-danger: oklch(0.56 0.2 25);
18
+ --color-adm-danger-fg: oklch(0.99 0 0);
19
+ --color-adm-success: oklch(0.58 0.14 155);
20
+ --color-adm-warning: oklch(0.75 0.15 75);
21
+ --color-adm-ring: oklch(0.52 0.18 262 / 0.35);
22
+ --color-adm-overlay: oklch(0.2 0.02 260 / 0.45);
23
+
24
+ --radius-adm: 0.5rem;
25
+ --font-adm: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
26
+ --font-adm-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
27
+ }
28
+
29
+ @layer theme {
30
+ /* Native controls (checkboxes, selects, date pickers) follow the theme. */
31
+ :root { color-scheme: light dark; }
32
+ :root.light { color-scheme: light; }
33
+
34
+ :root.dark {
35
+ color-scheme: dark;
36
+ --color-adm-bg: oklch(0.18 0.012 260);
37
+ --color-adm-surface: oklch(0.23 0.014 260);
38
+ --color-adm-surface-muted: oklch(0.27 0.014 260);
39
+ --color-adm-border: oklch(0.33 0.014 260);
40
+ --color-adm-text: oklch(0.93 0.008 260);
41
+ --color-adm-muted: oklch(0.7 0.015 260);
42
+ --color-adm-primary: oklch(0.68 0.15 262);
43
+ --color-adm-primary-fg: oklch(0.16 0.02 260);
44
+ --color-adm-ring: oklch(0.68 0.15 262 / 0.4);
45
+ --color-adm-overlay: oklch(0.05 0 0 / 0.6);
46
+ }
47
+
48
+ @media (prefers-color-scheme: dark) {
49
+ :root:not(.light) {
50
+ --color-adm-bg: oklch(0.18 0.012 260);
51
+ --color-adm-surface: oklch(0.23 0.014 260);
52
+ --color-adm-surface-muted: oklch(0.27 0.014 260);
53
+ --color-adm-border: oklch(0.33 0.014 260);
54
+ --color-adm-text: oklch(0.93 0.008 260);
55
+ --color-adm-muted: oklch(0.7 0.015 260);
56
+ --color-adm-primary: oklch(0.68 0.15 262);
57
+ --color-adm-primary-fg: oklch(0.16 0.02 260);
58
+ --color-adm-ring: oklch(0.68 0.15 262 / 0.4);
59
+ --color-adm-overlay: oklch(0.05 0 0 / 0.6);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ class ApplicationController < Administratum.config.parent_controller.constantize
5
+ protect_from_forgery with: :exception
6
+
7
+ layout "administratum/application"
8
+
9
+ helper Administratum::ApplicationHelper
10
+ Administratum.config.helpers.each { |mod| helper mod }
11
+
12
+ before_action :authenticate_admin!
13
+
14
+ helper_method :current_admin
15
+
16
+ private
17
+
18
+ def current_admin
19
+ current_administratum_user
20
+ end
21
+
22
+ # Not Devise's authenticate_*! helper: that redirects through Devise's
23
+ # failure app, which assumes Devise's own routes and controllers.
24
+ def authenticate_admin!
25
+ return if warden.authenticate(scope: :administratum_user)
26
+
27
+ store_location_for(:administratum_user, request.fullpath) if request.get? && is_navigational_format?
28
+ redirect_to administratum.sign_in_path, alert: t("administratum.sessions.unauthenticated")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ class DashboardController < ApplicationController
5
+ def index; end
6
+ end
7
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Base class for custom pages declared in app/admin: a controller with a
5
+ # menu entry and its own route, for reports, tools and anything that isn't
6
+ # CRUD on one model. Templates are looked up in app/views/admin/<route_key>
7
+ # first; the default index renders the page header and a `_content` partial.
8
+ class Page < ApplicationController
9
+ extend Administratum::PageDSL
10
+ include Pagy::Backend
11
+
12
+ helper_method :definition, :page_class, :page_path, :action_path
13
+
14
+ def index; end
15
+
16
+ # Same as Resource: point host-defined pages at the engine's url helpers.
17
+ def self.inherited(subclass)
18
+ super
19
+ subclass.include(Administratum::Engine.routes.url_helpers)
20
+ subclass.include(Administratum::Engine.routes.mounted_helpers)
21
+ end
22
+
23
+ def self.controller_path
24
+ name.nil? || abstract_page? ? super : "administratum/pages/#{controller_id}"
25
+ end
26
+
27
+ private
28
+
29
+ def _prefixes
30
+ @_prefixes ||= ["#{Administratum.config.view_prefix}/#{page_class.route_key}", "administratum/pages"]
31
+ end
32
+
33
+ def page_class
34
+ self.class
35
+ end
36
+
37
+ def definition
38
+ page_class.definition
39
+ end
40
+
41
+ def page_path(**)
42
+ public_send(:"#{page_class.route_key}_page_path", **)
43
+ end
44
+
45
+ # Path for a page_action. The second argument keeps the signature buttons use for resources.
46
+ def action_path(name, _record = nil, **)
47
+ definition.custom_actions.fetch(name.to_sym)
48
+ public_send(:"#{name}_#{page_class.route_key}_page_path", **)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ class PasswordResetsController < ApplicationController
5
+ layout "administratum/authentication"
6
+
7
+ skip_before_action :authenticate_admin!
8
+
9
+ def new; end
10
+
11
+ # Responds the same whether or not the address exists, so the form can't
12
+ # be used to discover admin emails.
13
+ def create
14
+ user = Administratum::User.find_for_authentication(email: params[:email].to_s)
15
+ if user
16
+ token = user.send(:set_reset_password_token)
17
+ Administratum::UserMailer.with(user:, token:).reset_password.deliver_later
18
+ end
19
+ redirect_to administratum.sign_in_path, notice: t("administratum.password_resets.sent")
20
+ end
21
+
22
+ def edit
23
+ @token = params[:token]
24
+ end
25
+
26
+ def update
27
+ @token = params[:token]
28
+ user = Administratum::User.reset_password_by_token(
29
+ reset_password_token: @token,
30
+ password: params[:password],
31
+ password_confirmation: params[:password_confirmation]
32
+ )
33
+
34
+ if user.errors.empty?
35
+ redirect_to administratum.sign_in_path, notice: t("administratum.password_resets.updated")
36
+ else
37
+ @errors = user.errors.full_messages
38
+ render :edit, status: :unprocessable_content
39
+ end
40
+ end
41
+ end
42
+ end