@actuate-media/cms-admin 0.26.0 → 0.28.0
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.
- package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +175 -0
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ThemeProvider.d.ts +47 -4
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +173 -27
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/Card.d.ts +2 -2
- package/dist/components/ui/Input.d.ts +1 -1
- package/dist/components/ui/Select.d.ts +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +47 -8
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -17
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
- package/dist/views/settings/AccessProtectionCard.js +20 -0
- package/dist/views/settings/AccessProtectionCard.js.map +1 -0
- package/dist/views/settings/AdminThemeCard.d.ts +6 -0
- package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
- package/dist/views/settings/AdminThemeCard.js +31 -0
- package/dist/views/settings/AdminThemeCard.js.map +1 -0
- package/dist/views/settings/AppearanceTab.d.ts +7 -0
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
- package/dist/views/settings/AppearanceTab.js +24 -0
- package/dist/views/settings/AppearanceTab.js.map +1 -0
- package/dist/views/settings/AuthMfaCard.d.ts +10 -0
- package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
- package/dist/views/settings/AuthMfaCard.js +33 -0
- package/dist/views/settings/AuthMfaCard.js.map +1 -0
- package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
- package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
- package/dist/views/settings/BrandPreviewCard.js +18 -0
- package/dist/views/settings/BrandPreviewCard.js.map +1 -0
- package/dist/views/settings/BrandingCard.d.ts +6 -0
- package/dist/views/settings/BrandingCard.d.ts.map +1 -0
- package/dist/views/settings/BrandingCard.js +38 -0
- package/dist/views/settings/BrandingCard.js.map +1 -0
- package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
- package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
- package/dist/views/settings/SecretsAuditCard.js +22 -0
- package/dist/views/settings/SecretsAuditCard.js.map +1 -0
- package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
- package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SecurityStatusCard.js +35 -0
- package/dist/views/settings/SecurityStatusCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts +5 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -0
- package/dist/views/settings/SecurityTab.js +41 -0
- package/dist/views/settings/SecurityTab.js.map +1 -0
- package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
- package/dist/views/settings/SessionPolicyCard.js +21 -0
- package/dist/views/settings/SessionPolicyCard.js.map +1 -0
- package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
- package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
- package/dist/views/settings/TypographyMotionCard.js +8 -0
- package/dist/views/settings/TypographyMotionCard.js.map +1 -0
- package/dist/views/settings/securityPrimitives.d.ts +21 -0
- package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
- package/dist/views/settings/securityPrimitives.js +31 -0
- package/dist/views/settings/securityPrimitives.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
- package/dist/views/settings/useAppearanceSettings.js +344 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +4 -1
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
- package/dist/views/settings/useGeneralSettings.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +37 -0
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
- package/dist/views/settings/useSecuritySettings.js +154 -0
- package/dist/views/settings/useSecuritySettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
- package/src/__tests__/views/security-settings.render.test.tsx +212 -0
- package/src/components/ThemeProvider.tsx +249 -36
- package/src/layout/Sidebar.tsx +81 -13
- package/src/styles/theme.css +32 -2
- package/src/views/Settings.tsx +5 -68
- package/src/views/settings/AccessProtectionCard.tsx +143 -0
- package/src/views/settings/AdminThemeCard.tsx +145 -0
- package/src/views/settings/AppearanceTab.tsx +81 -0
- package/src/views/settings/AuthMfaCard.tsx +118 -0
- package/src/views/settings/BrandPreviewCard.tsx +125 -0
- package/src/views/settings/BrandingCard.tsx +332 -0
- package/src/views/settings/SecretsAuditCard.tsx +129 -0
- package/src/views/settings/SecurityStatusCard.tsx +110 -0
- package/src/views/settings/SecurityTab.tsx +162 -0
- package/src/views/settings/SessionPolicyCard.tsx +123 -0
- package/src/views/settings/TypographyMotionCard.tsx +65 -0
- package/src/views/settings/securityPrimitives.tsx +75 -0
- package/src/views/settings/useAppearanceSettings.ts +513 -0
- package/src/views/settings/useGeneralSettings.ts +4 -1
- package/src/views/settings/useSecuritySettings.ts +197 -0
|
@@ -1,19 +1,101 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
accentTokensToCssVars,
|
|
5
|
+
generateAdminThemeTokens,
|
|
6
|
+
type ColorMode,
|
|
7
|
+
type SidebarDensity,
|
|
8
|
+
} from '@actuate-media/cms-core/appearance'
|
|
3
9
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Admin appearance engine.
|
|
13
|
+
*
|
|
14
|
+
* Owns the live, DOM-applied admin theme: color mode (light/dark/system),
|
|
15
|
+
* accent color, sidebar density, content font, reduced motion, and the
|
|
16
|
+
* resolved admin brand logos used by the sidebar. State is mirrored to
|
|
17
|
+
* localStorage so a reload paints instantly without a render-blocking fetch;
|
|
18
|
+
* the Appearance tab pushes the DB-backed values in via `applyAppearance` on
|
|
19
|
+
* load and save, making the database the source of truth and localStorage the
|
|
20
|
+
* warm cache.
|
|
21
|
+
*
|
|
22
|
+
* Backward compatible: `useTheme()` still exposes `{ theme, resolvedTheme,
|
|
23
|
+
* setTheme }`; the extra fields are additive.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Resolved admin logo URLs (cached for the sidebar to paint without a fetch). */
|
|
27
|
+
export interface AdminBrandCache {
|
|
28
|
+
primaryUrl: string | null
|
|
29
|
+
markUrl: string | null
|
|
30
|
+
darkUrl: string | null
|
|
31
|
+
initials: string
|
|
32
|
+
}
|
|
6
33
|
|
|
7
34
|
interface ThemeContextValue {
|
|
8
|
-
theme:
|
|
35
|
+
theme: ColorMode
|
|
9
36
|
resolvedTheme: 'light' | 'dark'
|
|
10
|
-
setTheme: (theme:
|
|
37
|
+
setTheme: (theme: ColorMode) => void
|
|
38
|
+
accent: string
|
|
39
|
+
setAccent: (accent: string) => void
|
|
40
|
+
density: SidebarDensity
|
|
41
|
+
setDensity: (density: SidebarDensity) => void
|
|
42
|
+
font: string
|
|
43
|
+
setFont: (font: string) => void
|
|
44
|
+
reduceMotion: boolean
|
|
45
|
+
setReduceMotion: (value: boolean) => void
|
|
46
|
+
adminBrand: AdminBrandCache
|
|
47
|
+
setAdminBrand: (brand: AdminBrandCache) => void
|
|
48
|
+
/** Push a full settings snapshot live (used by the Appearance tab). */
|
|
49
|
+
applyAppearance: (snapshot: AppearanceSnapshot) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AppearanceSnapshot {
|
|
53
|
+
theme?: ColorMode
|
|
54
|
+
accent?: string
|
|
55
|
+
density?: SidebarDensity
|
|
56
|
+
font?: string
|
|
57
|
+
reduceMotion?: boolean
|
|
58
|
+
adminBrand?: AdminBrandCache
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Curated content-font options. Applied as family strings (no web-font load). */
|
|
62
|
+
export const AVAILABLE_FONTS = ['DM Sans', 'Inter', 'Geist', 'System UI'] as const
|
|
63
|
+
|
|
64
|
+
const SYSTEM_STACK = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
65
|
+
|
|
66
|
+
const FONT_STACKS: Record<string, string> = {
|
|
67
|
+
'DM Sans': `'DM Sans', ${SYSTEM_STACK}`,
|
|
68
|
+
Inter: `'Inter', ${SYSTEM_STACK}`,
|
|
69
|
+
Geist: `'Geist', ${SYSTEM_STACK}`,
|
|
70
|
+
'System UI': SYSTEM_STACK,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fontStack(font: string): string {
|
|
74
|
+
return FONT_STACKS[font] ?? `'${font}', ${SYSTEM_STACK}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DEFAULT_BRAND: AdminBrandCache = {
|
|
78
|
+
primaryUrl: null,
|
|
79
|
+
markUrl: null,
|
|
80
|
+
darkUrl: null,
|
|
81
|
+
initials: 'A',
|
|
11
82
|
}
|
|
12
83
|
|
|
13
84
|
const ThemeContext = createContext<ThemeContextValue>({
|
|
14
85
|
theme: 'system',
|
|
15
86
|
resolvedTheme: 'light',
|
|
16
87
|
setTheme: () => {},
|
|
88
|
+
accent: 'purple',
|
|
89
|
+
setAccent: () => {},
|
|
90
|
+
density: 'default',
|
|
91
|
+
setDensity: () => {},
|
|
92
|
+
font: 'DM Sans',
|
|
93
|
+
setFont: () => {},
|
|
94
|
+
reduceMotion: false,
|
|
95
|
+
setReduceMotion: () => {},
|
|
96
|
+
adminBrand: DEFAULT_BRAND,
|
|
97
|
+
setAdminBrand: () => {},
|
|
98
|
+
applyAppearance: () => {},
|
|
17
99
|
})
|
|
18
100
|
|
|
19
101
|
export function useTheme() {
|
|
@@ -25,74 +107,205 @@ function getSystemTheme(): 'light' | 'dark' {
|
|
|
25
107
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
26
108
|
}
|
|
27
109
|
|
|
28
|
-
function resolveTheme(theme:
|
|
110
|
+
function resolveTheme(theme: ColorMode): 'light' | 'dark' {
|
|
29
111
|
return theme === 'system' ? getSystemTheme() : theme
|
|
30
112
|
}
|
|
31
113
|
|
|
32
|
-
const
|
|
114
|
+
const KEY_THEME = 'actuate-theme'
|
|
115
|
+
const KEY_ACCENT = 'actuate-accent'
|
|
116
|
+
const KEY_DENSITY = 'actuate-density'
|
|
117
|
+
const KEY_FONT = 'actuate-font'
|
|
118
|
+
const KEY_MOTION = 'actuate-reduce-motion'
|
|
119
|
+
const KEY_BRAND = 'actuate-admin-brand'
|
|
120
|
+
|
|
121
|
+
function adminRootEl(): HTMLElement | null {
|
|
122
|
+
return document.querySelector('.actuate-admin')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function applyColorMode(resolved: 'light' | 'dark') {
|
|
126
|
+
const el = adminRootEl()
|
|
127
|
+
if (!el) return
|
|
128
|
+
el.classList.toggle('dark', resolved === 'dark')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applyAccent(accent: string, resolved: 'light' | 'dark') {
|
|
132
|
+
const el = adminRootEl()
|
|
133
|
+
if (!el) return
|
|
134
|
+
const vars = accentTokensToCssVars(
|
|
135
|
+
generateAdminThemeTokens({ adminAccentColor: accent }, resolved),
|
|
136
|
+
)
|
|
137
|
+
for (const [name, value] of Object.entries(vars)) el.style.setProperty(name, value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyDensity(density: SidebarDensity) {
|
|
141
|
+
const el = adminRootEl()
|
|
142
|
+
if (!el) return
|
|
143
|
+
el.setAttribute('data-density', density)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyFont(font: string) {
|
|
147
|
+
const el = adminRootEl()
|
|
148
|
+
if (!el) return
|
|
149
|
+
el.style.setProperty('--actuate-admin-font', fontStack(font))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function applyReduceMotion(value: boolean) {
|
|
153
|
+
const el = adminRootEl()
|
|
154
|
+
if (!el) return
|
|
155
|
+
el.classList.toggle('reduce-motion', value)
|
|
156
|
+
}
|
|
33
157
|
|
|
34
158
|
interface ThemeProviderProps {
|
|
35
159
|
children: ReactNode
|
|
36
|
-
/** When true, default to dark mode if the user hasn't explicitly chosen
|
|
160
|
+
/** When true, default to dark mode if the user hasn't explicitly chosen one. */
|
|
37
161
|
defaultDarkMode?: boolean
|
|
38
162
|
}
|
|
39
163
|
|
|
40
164
|
export function ThemeProvider({ children, defaultDarkMode }: ThemeProviderProps) {
|
|
41
|
-
const [theme, setThemeState] = useState<
|
|
165
|
+
const [theme, setThemeState] = useState<ColorMode>('system')
|
|
42
166
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light')
|
|
167
|
+
const [accent, setAccentState] = useState('purple')
|
|
168
|
+
const [density, setDensityState] = useState<SidebarDensity>('default')
|
|
169
|
+
const [font, setFontState] = useState('DM Sans')
|
|
170
|
+
const [reduceMotion, setReduceMotionState] = useState(false)
|
|
171
|
+
const [adminBrand, setAdminBrandState] = useState<AdminBrandCache>(DEFAULT_BRAND)
|
|
43
172
|
|
|
173
|
+
// Hydrate from localStorage on mount and apply everything to the DOM.
|
|
44
174
|
useEffect(() => {
|
|
45
|
-
const
|
|
46
|
-
let
|
|
47
|
-
if (
|
|
48
|
-
|
|
175
|
+
const storedTheme = localStorage.getItem(KEY_THEME) as ColorMode | null
|
|
176
|
+
let initialTheme: ColorMode
|
|
177
|
+
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
|
|
178
|
+
initialTheme = storedTheme
|
|
49
179
|
} else if (defaultDarkMode === true) {
|
|
50
|
-
|
|
180
|
+
initialTheme = 'dark'
|
|
51
181
|
} else if (defaultDarkMode === false) {
|
|
52
|
-
|
|
182
|
+
initialTheme = 'light'
|
|
53
183
|
} else {
|
|
54
|
-
|
|
184
|
+
initialTheme = 'system'
|
|
55
185
|
}
|
|
56
|
-
|
|
57
|
-
|
|
186
|
+
const resolved = resolveTheme(initialTheme)
|
|
187
|
+
|
|
188
|
+
const storedAccent = localStorage.getItem(KEY_ACCENT) || 'purple'
|
|
189
|
+
const storedDensity = (localStorage.getItem(KEY_DENSITY) as SidebarDensity | null) || 'default'
|
|
190
|
+
const storedFont = localStorage.getItem(KEY_FONT) || 'DM Sans'
|
|
191
|
+
const storedMotion = localStorage.getItem(KEY_MOTION) === 'true'
|
|
192
|
+
let storedBrand = DEFAULT_BRAND
|
|
193
|
+
try {
|
|
194
|
+
const raw = localStorage.getItem(KEY_BRAND)
|
|
195
|
+
if (raw) storedBrand = { ...DEFAULT_BRAND, ...JSON.parse(raw) }
|
|
196
|
+
} catch {
|
|
197
|
+
// Ignore malformed cache.
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setThemeState(initialTheme)
|
|
58
201
|
setResolvedTheme(resolved)
|
|
59
|
-
|
|
202
|
+
setAccentState(storedAccent)
|
|
203
|
+
setDensityState(storedDensity)
|
|
204
|
+
setFontState(storedFont)
|
|
205
|
+
setReduceMotionState(storedMotion)
|
|
206
|
+
setAdminBrandState(storedBrand)
|
|
207
|
+
|
|
208
|
+
applyColorMode(resolved)
|
|
209
|
+
applyAccent(storedAccent, resolved)
|
|
210
|
+
applyDensity(storedDensity)
|
|
211
|
+
applyFont(storedFont)
|
|
212
|
+
applyReduceMotion(storedMotion)
|
|
60
213
|
}, [defaultDarkMode])
|
|
61
214
|
|
|
215
|
+
// Follow the OS color scheme while in `system` mode (re-applies accent too,
|
|
216
|
+
// since presets differ between light and dark).
|
|
62
217
|
useEffect(() => {
|
|
63
218
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
64
219
|
const handler = () => {
|
|
65
220
|
if (theme === 'system') {
|
|
66
221
|
const resolved = getSystemTheme()
|
|
67
222
|
setResolvedTheme(resolved)
|
|
68
|
-
|
|
223
|
+
applyColorMode(resolved)
|
|
224
|
+
applyAccent(accent, resolved)
|
|
69
225
|
}
|
|
70
226
|
}
|
|
71
227
|
mq.addEventListener('change', handler)
|
|
72
228
|
return () => mq.removeEventListener('change', handler)
|
|
73
|
-
}, [theme])
|
|
229
|
+
}, [theme, accent])
|
|
74
230
|
|
|
75
|
-
const setTheme = useCallback(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
231
|
+
const setTheme = useCallback(
|
|
232
|
+
(next: ColorMode) => {
|
|
233
|
+
setThemeState(next)
|
|
234
|
+
localStorage.setItem(KEY_THEME, next)
|
|
235
|
+
const resolved = resolveTheme(next)
|
|
236
|
+
setResolvedTheme(resolved)
|
|
237
|
+
applyColorMode(resolved)
|
|
238
|
+
applyAccent(accent, resolved)
|
|
239
|
+
},
|
|
240
|
+
[accent],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const setAccent = useCallback(
|
|
244
|
+
(next: string) => {
|
|
245
|
+
setAccentState(next)
|
|
246
|
+
localStorage.setItem(KEY_ACCENT, next)
|
|
247
|
+
applyAccent(next, resolvedTheme)
|
|
248
|
+
},
|
|
249
|
+
[resolvedTheme],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const setDensity = useCallback((next: SidebarDensity) => {
|
|
253
|
+
setDensityState(next)
|
|
254
|
+
localStorage.setItem(KEY_DENSITY, next)
|
|
255
|
+
applyDensity(next)
|
|
256
|
+
}, [])
|
|
257
|
+
|
|
258
|
+
const setFont = useCallback((next: string) => {
|
|
259
|
+
setFontState(next)
|
|
260
|
+
localStorage.setItem(KEY_FONT, next)
|
|
261
|
+
applyFont(next)
|
|
81
262
|
}, [])
|
|
82
263
|
|
|
264
|
+
const setReduceMotion = useCallback((next: boolean) => {
|
|
265
|
+
setReduceMotionState(next)
|
|
266
|
+
localStorage.setItem(KEY_MOTION, String(next))
|
|
267
|
+
applyReduceMotion(next)
|
|
268
|
+
}, [])
|
|
269
|
+
|
|
270
|
+
const setAdminBrand = useCallback((next: AdminBrandCache) => {
|
|
271
|
+
setAdminBrandState(next)
|
|
272
|
+
localStorage.setItem(KEY_BRAND, JSON.stringify(next))
|
|
273
|
+
}, [])
|
|
274
|
+
|
|
275
|
+
// Apply a full snapshot live (Appearance tab on load/save). Persists to
|
|
276
|
+
// localStorage so the choice survives reloads even before the next fetch.
|
|
277
|
+
const applyAppearance = useCallback(
|
|
278
|
+
(snapshot: AppearanceSnapshot) => {
|
|
279
|
+
if (snapshot.theme !== undefined) setTheme(snapshot.theme)
|
|
280
|
+
if (snapshot.accent !== undefined) setAccent(snapshot.accent)
|
|
281
|
+
if (snapshot.density !== undefined) setDensity(snapshot.density)
|
|
282
|
+
if (snapshot.font !== undefined) setFont(snapshot.font)
|
|
283
|
+
if (snapshot.reduceMotion !== undefined) setReduceMotion(snapshot.reduceMotion)
|
|
284
|
+
if (snapshot.adminBrand !== undefined) setAdminBrand(snapshot.adminBrand)
|
|
285
|
+
},
|
|
286
|
+
[setTheme, setAccent, setDensity, setFont, setReduceMotion, setAdminBrand],
|
|
287
|
+
)
|
|
288
|
+
|
|
83
289
|
return (
|
|
84
|
-
<ThemeContext.Provider
|
|
290
|
+
<ThemeContext.Provider
|
|
291
|
+
value={{
|
|
292
|
+
theme,
|
|
293
|
+
resolvedTheme,
|
|
294
|
+
setTheme,
|
|
295
|
+
accent,
|
|
296
|
+
setAccent,
|
|
297
|
+
density,
|
|
298
|
+
setDensity,
|
|
299
|
+
font,
|
|
300
|
+
setFont,
|
|
301
|
+
reduceMotion,
|
|
302
|
+
setReduceMotion,
|
|
303
|
+
adminBrand,
|
|
304
|
+
setAdminBrand,
|
|
305
|
+
applyAppearance,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
85
308
|
{children}
|
|
86
309
|
</ThemeContext.Provider>
|
|
87
310
|
)
|
|
88
311
|
}
|
|
89
|
-
|
|
90
|
-
function applyTheme(resolved: 'light' | 'dark') {
|
|
91
|
-
const adminRoot = document.querySelector('.actuate-admin')
|
|
92
|
-
if (!adminRoot) return
|
|
93
|
-
if (resolved === 'dark') {
|
|
94
|
-
adminRoot.classList.add('dark')
|
|
95
|
-
} else {
|
|
96
|
-
adminRoot.classList.remove('dark')
|
|
97
|
-
}
|
|
98
|
-
}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -76,26 +76,55 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
76
76
|
Layers: Layers,
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Square site-initials badge — the final fallback before the Actuate mark. */
|
|
80
|
+
function InitialsBadge({ initials, size }: { initials: string; size: 'sm' | 'lg' }) {
|
|
81
|
+
return (
|
|
82
|
+
<span
|
|
83
|
+
className={`bg-sidebar-primary text-sidebar-primary-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${
|
|
84
|
+
size === 'lg' ? 'h-8 w-8 text-sm' : 'h-8 w-8 text-sm'
|
|
85
|
+
}`}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
{initials}
|
|
89
|
+
</span>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sidebar brand lockup.
|
|
95
|
+
*
|
|
96
|
+
* Resolution order follows the Appearance spec, sourcing from the live
|
|
97
|
+
* appearance context (DB-backed brand, cached in localStorage) and falling
|
|
98
|
+
* back to legacy `config.admin.branding`, then site initials, then the bundled
|
|
99
|
+
* Actuate mark/wordmark for un-branded installs. Dark-mode uses the dark logo
|
|
100
|
+
* when one is configured. Logos are never stretched/cropped (object-contain).
|
|
101
|
+
*/
|
|
79
102
|
function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
|
|
103
|
+
const { adminBrand, resolvedTheme } = useTheme()
|
|
80
104
|
const branding = config?.admin?.branding
|
|
81
|
-
const
|
|
105
|
+
const configLogo = branding?.logo
|
|
82
106
|
const brandName = branding?.name
|
|
107
|
+
const initials = adminBrand.initials
|
|
108
|
+
const hasInitials = Boolean(initials) && initials !== 'A'
|
|
109
|
+
|
|
110
|
+
// Prefer the dark logo on dark backgrounds when supplied.
|
|
111
|
+
const primary =
|
|
112
|
+
resolvedTheme === 'dark' && adminBrand.darkUrl ? adminBrand.darkUrl : adminBrand.primaryUrl
|
|
113
|
+
const mark = adminBrand.markUrl
|
|
114
|
+
const alt = brandName ?? 'Admin'
|
|
83
115
|
|
|
84
116
|
if (collapsed) {
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
}
|
|
117
|
+
if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
|
|
118
|
+
if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
|
|
119
|
+
if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
|
|
120
|
+
if (hasInitials) return <InitialsBadge initials={initials} size="sm" />
|
|
88
121
|
return <ActuateMark className="h-8 w-8" />
|
|
89
122
|
}
|
|
90
123
|
|
|
91
|
-
if (
|
|
124
|
+
if (primary) {
|
|
92
125
|
return (
|
|
93
126
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
94
|
-
<img
|
|
95
|
-
src={customLogo}
|
|
96
|
-
alt={brandName ?? 'Admin'}
|
|
97
|
-
className="h-8 w-auto shrink-0 object-contain"
|
|
98
|
-
/>
|
|
127
|
+
<img src={primary} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
|
|
99
128
|
{brandName && (
|
|
100
129
|
<span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
|
|
101
130
|
)}
|
|
@@ -103,6 +132,32 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
103
132
|
)
|
|
104
133
|
}
|
|
105
134
|
|
|
135
|
+
if (configLogo) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
138
|
+
<img src={configLogo} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
|
|
139
|
+
{brandName && (
|
|
140
|
+
<span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (mark || hasInitials) {
|
|
147
|
+
return (
|
|
148
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
149
|
+
{mark ? (
|
|
150
|
+
<img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
|
|
151
|
+
) : (
|
|
152
|
+
<InitialsBadge initials={initials} size="lg" />
|
|
153
|
+
)}
|
|
154
|
+
<span className="text-sidebar-foreground truncate text-sm font-medium">
|
|
155
|
+
{brandName ?? alt}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
if (brandName) {
|
|
107
162
|
return (
|
|
108
163
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
@@ -180,6 +235,8 @@ export function Sidebar({
|
|
|
180
235
|
}: SidebarProps) {
|
|
181
236
|
const formsSidebar = useFormsSidebar()
|
|
182
237
|
const navItems = applyFormsNav(buildNavItems(config), formsSidebar)
|
|
238
|
+
const { density } = useTheme()
|
|
239
|
+
const compact = density === 'compact'
|
|
183
240
|
|
|
184
241
|
return (
|
|
185
242
|
<aside
|
|
@@ -196,8 +253,11 @@ export function Sidebar({
|
|
|
196
253
|
<BrandLogo config={config} collapsed={collapsed} />
|
|
197
254
|
</div>
|
|
198
255
|
|
|
199
|
-
<nav
|
|
200
|
-
{
|
|
256
|
+
<nav
|
|
257
|
+
className={`flex-1 overflow-y-auto ${compact ? 'space-y-0.5 p-2' : 'space-y-1 p-3'}`}
|
|
258
|
+
aria-label="Primary"
|
|
259
|
+
>
|
|
260
|
+
{renderNavTree(navItems, { collapsed, currentPath, onNavigate, compact })}
|
|
201
261
|
</nav>
|
|
202
262
|
|
|
203
263
|
{/* Footer collapse control. Sits at the bottom of the sidebar so it
|
|
@@ -303,6 +363,8 @@ interface NavRenderContext {
|
|
|
303
363
|
collapsed: boolean
|
|
304
364
|
currentPath: string
|
|
305
365
|
onNavigate: (path: string) => void
|
|
366
|
+
/** Compact density tightens nav row vertical padding. */
|
|
367
|
+
compact?: boolean
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
function isPathActive(currentPath: string, itemPath: string): boolean {
|
|
@@ -472,7 +534,13 @@ function NavLink({
|
|
|
472
534
|
<button
|
|
473
535
|
onClick={() => ctx.onNavigate(item.path)}
|
|
474
536
|
className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
|
|
475
|
-
nested
|
|
537
|
+
nested
|
|
538
|
+
? ctx.compact
|
|
539
|
+
? 'py-1 pr-3 pl-9'
|
|
540
|
+
: 'py-1.5 pr-3 pl-9'
|
|
541
|
+
: ctx.compact
|
|
542
|
+
? 'px-3 py-1.5'
|
|
543
|
+
: 'px-3 py-2.5'
|
|
476
544
|
} ${
|
|
477
545
|
isActive
|
|
478
546
|
? 'bg-sidebar-accent text-sidebar-primary'
|
package/src/styles/theme.css
CHANGED
|
@@ -166,13 +166,23 @@
|
|
|
166
166
|
|
|
167
167
|
.actuate-admin {
|
|
168
168
|
@apply bg-background text-foreground;
|
|
169
|
-
|
|
169
|
+
/*
|
|
170
|
+
* Content font is var-driven so the Appearance tab can switch it at
|
|
171
|
+
* runtime, but defaults to the system-ui stack. We do NOT load web fonts
|
|
172
|
+
* here (no Google Fonts / @font-face) — the chosen family is applied as a
|
|
173
|
+
* family string and falls back to system-ui when not installed, keeping
|
|
174
|
+
* the admin dependency-free per the design system. This is the single
|
|
175
|
+
* permitted `!important` (it must beat consumer global resets).
|
|
176
|
+
*/
|
|
177
|
+
font-family: var(
|
|
178
|
+
--actuate-admin-font,
|
|
170
179
|
system-ui,
|
|
171
180
|
-apple-system,
|
|
172
181
|
BlinkMacSystemFont,
|
|
173
182
|
'Segoe UI',
|
|
174
183
|
Roboto,
|
|
175
|
-
sans-serif
|
|
184
|
+
sans-serif
|
|
185
|
+
) !important;
|
|
176
186
|
font-size: var(--font-size);
|
|
177
187
|
line-height: 1.5;
|
|
178
188
|
isolation: isolate;
|
|
@@ -241,3 +251,23 @@
|
|
|
241
251
|
line-height: 1.5;
|
|
242
252
|
}
|
|
243
253
|
}
|
|
254
|
+
|
|
255
|
+
/*
|
|
256
|
+
* Reduce-motion (admin-level Appearance toggle).
|
|
257
|
+
*
|
|
258
|
+
* Intentionally UNLAYERED: unlayered rules beat Tailwind's `@layer utilities`
|
|
259
|
+
* transition/animation classes without needing `!important`, so the toggle can
|
|
260
|
+
* neutralize utility-driven motion. We shorten (not delete) durations and never
|
|
261
|
+
* touch outlines/focus rings, so state changes and focus visibility remain
|
|
262
|
+
* intact. The OS `prefers-reduced-motion` is still respected app-wide via
|
|
263
|
+
* `motion-safe:`/`motion-reduce:`; this class lets users opt in regardless.
|
|
264
|
+
*/
|
|
265
|
+
.actuate-admin.reduce-motion,
|
|
266
|
+
.actuate-admin.reduce-motion *,
|
|
267
|
+
.actuate-admin.reduce-motion *::before,
|
|
268
|
+
.actuate-admin.reduce-motion *::after {
|
|
269
|
+
animation-duration: 0.001ms;
|
|
270
|
+
animation-iteration-count: 1;
|
|
271
|
+
transition-duration: 0.001ms;
|
|
272
|
+
scroll-behavior: auto;
|
|
273
|
+
}
|
package/src/views/Settings.tsx
CHANGED
|
@@ -26,10 +26,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
|
26
26
|
import { toast } from 'sonner'
|
|
27
27
|
import { useApiData } from '../lib/useApiData.js'
|
|
28
28
|
import { cmsApi } from '../lib/api.js'
|
|
29
|
-
import { useTheme } from '../components/ThemeProvider.js'
|
|
30
29
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
31
30
|
import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
|
|
32
31
|
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
32
|
+
import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
33
|
+
import { SecurityTab } from './settings/SecurityTab.js'
|
|
33
34
|
import { Users as UsersView } from './Users.js'
|
|
34
35
|
import { ApiKeys as ApiKeysView } from './ApiKeys.js'
|
|
35
36
|
|
|
@@ -44,7 +45,7 @@ export interface SettingsProps {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Tabs that drive their own persistence (no global "Save Changes" footer). */
|
|
47
|
-
const SELF_MANAGED_TABS = new Set(['general', 'users', 'api-keys'])
|
|
48
|
+
const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'security', 'users', 'api-keys'])
|
|
48
49
|
|
|
49
50
|
function readSettingsTabFromUrl(): string | null {
|
|
50
51
|
if (typeof window === 'undefined') return null
|
|
@@ -59,9 +60,6 @@ export function Settings({
|
|
|
59
60
|
}: SettingsProps = {}) {
|
|
60
61
|
const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
|
|
61
62
|
|
|
62
|
-
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
|
|
63
|
-
const [sessionTimeout, setSessionTimeout] = useState(false)
|
|
64
|
-
const [ipWhitelist, setIpWhitelist] = useState(false)
|
|
65
63
|
const [activeTab, setActiveTab] = useState<string>(() => readSettingsTabFromUrl() ?? initialTab)
|
|
66
64
|
const [saving, setSaving] = useState(false)
|
|
67
65
|
|
|
@@ -138,9 +136,6 @@ export function Settings({
|
|
|
138
136
|
|
|
139
137
|
useEffect(() => {
|
|
140
138
|
if (data) {
|
|
141
|
-
setTwoFactorEnabled(data.twoFactorEnabled ?? false)
|
|
142
|
-
setSessionTimeout(data.sessionTimeout ?? false)
|
|
143
|
-
setIpWhitelist(data.ipWhitelist ?? false)
|
|
144
139
|
setAiProvider(data.aiProvider ?? 'anthropic')
|
|
145
140
|
setAiAltTags(data.aiAltTags ?? true)
|
|
146
141
|
setAiMediaCategorize(data.aiMediaCategorize ?? false)
|
|
@@ -167,9 +162,6 @@ export function Settings({
|
|
|
167
162
|
method: 'PUT',
|
|
168
163
|
body: JSON.stringify({
|
|
169
164
|
...(current.data ?? {}),
|
|
170
|
-
twoFactorEnabled,
|
|
171
|
-
sessionTimeout,
|
|
172
|
-
ipWhitelist,
|
|
173
165
|
aiProvider,
|
|
174
166
|
aiAltTags,
|
|
175
167
|
aiMediaCategorize,
|
|
@@ -275,25 +267,7 @@ export function Settings({
|
|
|
275
267
|
</Tabs.Content>
|
|
276
268
|
|
|
277
269
|
<Tabs.Content value="appearance" className="space-y-4">
|
|
278
|
-
<
|
|
279
|
-
<h3 className="mb-4 text-sm font-semibold text-gray-900">Theme</h3>
|
|
280
|
-
<div className="space-y-4">
|
|
281
|
-
<div>
|
|
282
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">Color Scheme</label>
|
|
283
|
-
<ThemeSelect />
|
|
284
|
-
</div>
|
|
285
|
-
<div>
|
|
286
|
-
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
287
|
-
Primary Color
|
|
288
|
-
</label>
|
|
289
|
-
<input
|
|
290
|
-
type="color"
|
|
291
|
-
defaultValue="#3b82f6"
|
|
292
|
-
className="h-10 w-full rounded-lg border border-gray-300 px-3 py-2"
|
|
293
|
-
/>
|
|
294
|
-
</div>
|
|
295
|
-
</div>
|
|
296
|
-
</div>
|
|
270
|
+
<AppearanceTab role={session?.user?.role} config={config} />
|
|
297
271
|
</Tabs.Content>
|
|
298
272
|
|
|
299
273
|
{hasLayoutRegions && (
|
|
@@ -369,29 +343,7 @@ export function Settings({
|
|
|
369
343
|
)}
|
|
370
344
|
|
|
371
345
|
<Tabs.Content value="security" className="space-y-4">
|
|
372
|
-
<
|
|
373
|
-
<h3 className="mb-4 text-sm font-semibold text-gray-900">Security Settings</h3>
|
|
374
|
-
<div className="space-y-4">
|
|
375
|
-
<ToggleSetting
|
|
376
|
-
label="Two-Factor Authentication"
|
|
377
|
-
description="Require 2FA for all admin users"
|
|
378
|
-
checked={twoFactorEnabled}
|
|
379
|
-
onChange={setTwoFactorEnabled}
|
|
380
|
-
/>
|
|
381
|
-
<ToggleSetting
|
|
382
|
-
label="Session Timeout"
|
|
383
|
-
description="Automatically log out inactive users after 30 minutes"
|
|
384
|
-
checked={sessionTimeout}
|
|
385
|
-
onChange={setSessionTimeout}
|
|
386
|
-
/>
|
|
387
|
-
<ToggleSetting
|
|
388
|
-
label="IP Whitelist"
|
|
389
|
-
description="Only allow access from approved IP addresses"
|
|
390
|
-
checked={ipWhitelist}
|
|
391
|
-
onChange={setIpWhitelist}
|
|
392
|
-
/>
|
|
393
|
-
</div>
|
|
394
|
-
</div>
|
|
346
|
+
<SecurityTab role={session?.user?.role} onNavigate={onNavigate} />
|
|
395
347
|
</Tabs.Content>
|
|
396
348
|
|
|
397
349
|
<Tabs.Content value="seo" className="space-y-4">
|
|
@@ -622,21 +574,6 @@ export function Settings({
|
|
|
622
574
|
)
|
|
623
575
|
}
|
|
624
576
|
|
|
625
|
-
function ThemeSelect() {
|
|
626
|
-
const { theme, setTheme } = useTheme()
|
|
627
|
-
return (
|
|
628
|
-
<select
|
|
629
|
-
value={theme}
|
|
630
|
-
onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}
|
|
631
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
632
|
-
>
|
|
633
|
-
<option value="light">Light</option>
|
|
634
|
-
<option value="dark">Dark</option>
|
|
635
|
-
<option value="system">Auto (System)</option>
|
|
636
|
-
</select>
|
|
637
|
-
)
|
|
638
|
-
}
|
|
639
|
-
|
|
640
577
|
function ToggleSetting({
|
|
641
578
|
label,
|
|
642
579
|
description,
|