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.
- checksums.yaml +7 -0
- data/DESIGN.md +68 -0
- data/MIT-LICENSE +20 -0
- data/README.md +431 -0
- data/Rakefile +6 -0
- data/app/admin/administratum/user_admin.rb +39 -0
- data/app/assets/builds/administratum.css +2 -0
- data/app/assets/javascripts/administratum.js +250 -0
- data/app/assets/tailwind/administratum/components.css +199 -0
- data/app/assets/tailwind/administratum/index.css +8 -0
- data/app/assets/tailwind/administratum/theme.css +62 -0
- data/app/controllers/administratum/application_controller.rb +31 -0
- data/app/controllers/administratum/dashboard_controller.rb +7 -0
- data/app/controllers/administratum/page.rb +51 -0
- data/app/controllers/administratum/password_resets_controller.rb +42 -0
- data/app/controllers/administratum/resource.rb +224 -0
- data/app/controllers/administratum/sessions_controller.rb +33 -0
- data/app/helpers/administratum/application_helper.rb +11 -0
- data/app/helpers/administratum/form_helper.rb +115 -0
- data/app/helpers/administratum/island_helper.rb +16 -0
- data/app/helpers/administratum/layout_helper.rb +81 -0
- data/app/helpers/administratum/resource_helper.rb +199 -0
- data/app/mailers/administratum/application_mailer.rb +8 -0
- data/app/mailers/administratum/user_mailer.rb +11 -0
- data/app/models/administratum/application_record.rb +7 -0
- data/app/models/administratum/user.rb +13 -0
- data/app/views/administratum/dashboard/index.html.erb +16 -0
- data/app/views/administratum/fields/_belongs_to.html.erb +1 -0
- data/app/views/administratum/fields/_boolean.html.erb +1 -0
- data/app/views/administratum/fields/_date.html.erb +1 -0
- data/app/views/administratum/fields/_datetime.html.erb +1 -0
- data/app/views/administratum/fields/_email.html.erb +1 -0
- data/app/views/administratum/fields/_has_many_ids.html.erb +1 -0
- data/app/views/administratum/fields/_hidden.html.erb +1 -0
- data/app/views/administratum/fields/_island.html.erb +4 -0
- data/app/views/administratum/fields/_json.html.erb +1 -0
- data/app/views/administratum/fields/_multi_select.html.erb +1 -0
- data/app/views/administratum/fields/_number.html.erb +1 -0
- data/app/views/administratum/fields/_password.html.erb +1 -0
- data/app/views/administratum/fields/_select.html.erb +1 -0
- data/app/views/administratum/fields/_string.html.erb +1 -0
- data/app/views/administratum/fields/_textarea.html.erb +1 -0
- data/app/views/administratum/fields/_wrapper.html.erb +11 -0
- data/app/views/administratum/pages/index.html.erb +7 -0
- data/app/views/administratum/password_resets/edit.html.erb +17 -0
- data/app/views/administratum/password_resets/new.html.erb +11 -0
- data/app/views/administratum/resources/_attributes.html.erb +11 -0
- data/app/views/administratum/resources/_form.html.erb +14 -0
- data/app/views/administratum/resources/_table.html.erb +42 -0
- data/app/views/administratum/resources/edit.html.erb +7 -0
- data/app/views/administratum/resources/index.html.erb +17 -0
- data/app/views/administratum/resources/new.html.erb +7 -0
- data/app/views/administratum/resources/show.html.erb +7 -0
- data/app/views/administratum/sessions/new.html.erb +18 -0
- data/app/views/administratum/shared/_card.html.erb +9 -0
- data/app/views/administratum/shared/_filters.html.erb +43 -0
- data/app/views/administratum/shared/_flash.html.erb +9 -0
- data/app/views/administratum/shared/_form_errors.html.erb +10 -0
- data/app/views/administratum/shared/_nav.html.erb +10 -0
- data/app/views/administratum/shared/_page_header.html.erb +17 -0
- data/app/views/administratum/shared/_panel.html.erb +24 -0
- data/app/views/administratum/shared/_topbar.html.erb +29 -0
- data/app/views/administratum/user_mailer/reset_password.html.erb +4 -0
- data/app/views/administratum/user_mailer/reset_password.text.erb +7 -0
- data/app/views/layouts/administratum/application.html.erb +25 -0
- data/app/views/layouts/administratum/authentication.html.erb +32 -0
- data/config/locales/en.yml +65 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261016000000_create_administratum_users.rb +17 -0
- data/lib/administratum/configuration.rb +66 -0
- data/lib/administratum/definition.rb +283 -0
- data/lib/administratum/dsl.rb +195 -0
- data/lib/administratum/engine.rb +31 -0
- data/lib/administratum/field_types.rb +77 -0
- data/lib/administratum/page_dsl.rb +48 -0
- data/lib/administratum/paths.rb +85 -0
- data/lib/administratum/ransackable.rb +41 -0
- data/lib/administratum/registry.rb +95 -0
- data/lib/administratum/routing.rb +57 -0
- data/lib/administratum/tailwind.rb +34 -0
- data/lib/administratum/version.rb +5 -0
- data/lib/administratum.rb +43 -0
- data/lib/generators/administratum/install/install_generator.rb +42 -0
- data/lib/generators/administratum/install/templates/administratum.rb +41 -0
- data/lib/generators/administratum/resource/resource_generator.rb +30 -0
- data/lib/generators/administratum/resource/templates/resource.rb.tt +20 -0
- data/lib/tasks/administratum.rake +32 -0
- 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,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,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
|