@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.
Files changed (105) hide show
  1. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  4. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  8. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/ThemeProvider.d.ts +47 -4
  11. package/dist/components/ThemeProvider.d.ts.map +1 -1
  12. package/dist/components/ThemeProvider.js +173 -27
  13. package/dist/components/ThemeProvider.js.map +1 -1
  14. package/dist/components/ui/Badge.d.ts +1 -1
  15. package/dist/components/ui/Button.d.ts +1 -1
  16. package/dist/components/ui/Card.d.ts +2 -2
  17. package/dist/components/ui/Input.d.ts +1 -1
  18. package/dist/components/ui/Select.d.ts +1 -1
  19. package/dist/layout/Sidebar.d.ts.map +1 -1
  20. package/dist/layout/Sidebar.js +47 -8
  21. package/dist/layout/Sidebar.js.map +1 -1
  22. package/dist/views/Settings.d.ts.map +1 -1
  23. package/dist/views/Settings.js +5 -17
  24. package/dist/views/Settings.js.map +1 -1
  25. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  26. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  27. package/dist/views/settings/AccessProtectionCard.js +20 -0
  28. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  29. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  30. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  31. package/dist/views/settings/AdminThemeCard.js +31 -0
  32. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  33. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  34. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  35. package/dist/views/settings/AppearanceTab.js +24 -0
  36. package/dist/views/settings/AppearanceTab.js.map +1 -0
  37. package/dist/views/settings/AuthMfaCard.d.ts +10 -0
  38. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  39. package/dist/views/settings/AuthMfaCard.js +33 -0
  40. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  41. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  42. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandPreviewCard.js +18 -0
  44. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  45. package/dist/views/settings/BrandingCard.d.ts +6 -0
  46. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  47. package/dist/views/settings/BrandingCard.js +38 -0
  48. package/dist/views/settings/BrandingCard.js.map +1 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  50. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  51. package/dist/views/settings/SecretsAuditCard.js +22 -0
  52. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  54. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  55. package/dist/views/settings/SecurityStatusCard.js +35 -0
  56. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  57. package/dist/views/settings/SecurityTab.d.ts +5 -0
  58. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  59. package/dist/views/settings/SecurityTab.js +41 -0
  60. package/dist/views/settings/SecurityTab.js.map +1 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  62. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  63. package/dist/views/settings/SessionPolicyCard.js +21 -0
  64. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  65. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  66. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  67. package/dist/views/settings/TypographyMotionCard.js +8 -0
  68. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  69. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  70. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  71. package/dist/views/settings/securityPrimitives.js +31 -0
  72. package/dist/views/settings/securityPrimitives.js.map +1 -0
  73. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  74. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  75. package/dist/views/settings/useAppearanceSettings.js +344 -0
  76. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  77. package/dist/views/settings/useGeneralSettings.d.ts +4 -1
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  79. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  80. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  81. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  82. package/dist/views/settings/useSecuritySettings.js +154 -0
  83. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  84. package/package.json +2 -2
  85. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  86. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  87. package/src/components/ThemeProvider.tsx +249 -36
  88. package/src/layout/Sidebar.tsx +81 -13
  89. package/src/styles/theme.css +32 -2
  90. package/src/views/Settings.tsx +5 -68
  91. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  92. package/src/views/settings/AdminThemeCard.tsx +145 -0
  93. package/src/views/settings/AppearanceTab.tsx +81 -0
  94. package/src/views/settings/AuthMfaCard.tsx +118 -0
  95. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  96. package/src/views/settings/BrandingCard.tsx +332 -0
  97. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  98. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  99. package/src/views/settings/SecurityTab.tsx +162 -0
  100. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  101. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  102. package/src/views/settings/securityPrimitives.tsx +75 -0
  103. package/src/views/settings/useAppearanceSettings.ts +513 -0
  104. package/src/views/settings/useGeneralSettings.ts +4 -1
  105. package/src/views/settings/useSecuritySettings.ts +197 -0
@@ -0,0 +1,513 @@
1
+ 'use client'
2
+
3
+ import {
4
+ classifyLogoShape,
5
+ logoShapeWarning,
6
+ resolveAdminBrand,
7
+ resolvePublicBrand,
8
+ validateAccentColor,
9
+ type AdminBrandingMode,
10
+ type BrandAssetLike,
11
+ type BrandAssetRole,
12
+ type BrandSettings,
13
+ type ColorMode,
14
+ type LogoShape,
15
+ type ResolvedAdminBrand,
16
+ type ResolvedPublicBrand,
17
+ type SidebarDensity,
18
+ } from '@actuate-media/cms-core/appearance'
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
20
+
21
+ import { useTheme, type AdminBrandCache } from '../../components/ThemeProvider.js'
22
+ import { cmsApi } from '../../lib/api.js'
23
+ import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
24
+
25
+ export type SaveState = 'idle' | 'saving' | 'success' | 'error'
26
+
27
+ /** Theme + typography + motion form (the `appearance` settings blob). */
28
+ export interface AppearanceForm {
29
+ colorMode: ColorMode
30
+ accent: string
31
+ density: SidebarDensity
32
+ font: string
33
+ reduceMotion: boolean
34
+ }
35
+
36
+ /** Brand asset references + admin branding mode (the `brand` settings blob). */
37
+ export interface BrandForm {
38
+ mode: AdminBrandingMode
39
+ publicPrimaryLogoAssetId?: string
40
+ publicLogoMarkAssetId?: string
41
+ publicDarkLogoAssetId?: string
42
+ adminPrimaryLogoAssetId?: string
43
+ adminLogoMarkAssetId?: string
44
+ adminDarkLogoAssetId?: string
45
+ faviconSourceAssetId?: string
46
+ defaultLogoAltText?: string
47
+ }
48
+
49
+ /** A brand-form field that points at a media asset, plus its asset role. */
50
+ export type BrandAssetField =
51
+ | 'publicPrimaryLogoAssetId'
52
+ | 'publicLogoMarkAssetId'
53
+ | 'publicDarkLogoAssetId'
54
+ | 'adminPrimaryLogoAssetId'
55
+ | 'adminLogoMarkAssetId'
56
+ | 'adminDarkLogoAssetId'
57
+ | 'faviconSourceAssetId'
58
+
59
+ export const FIELD_ROLE: Record<BrandAssetField, BrandAssetRole> = {
60
+ publicPrimaryLogoAssetId: 'publicPrimaryLogo',
61
+ publicLogoMarkAssetId: 'publicLogoMark',
62
+ publicDarkLogoAssetId: 'publicDarkLogo',
63
+ adminPrimaryLogoAssetId: 'adminPrimaryLogo',
64
+ adminLogoMarkAssetId: 'adminLogoMark',
65
+ adminDarkLogoAssetId: 'adminDarkLogo',
66
+ faviconSourceAssetId: 'faviconSource',
67
+ }
68
+
69
+ export interface AssetPreview {
70
+ id: string
71
+ url: string
72
+ mimeType: string
73
+ width?: number
74
+ height?: number
75
+ shape: LogoShape
76
+ }
77
+
78
+ const LOGO_MIME = ['image/svg+xml', 'image/png']
79
+ const FAVICON_MIME = ['image/svg+xml', 'image/png']
80
+ const MAX_BYTES = 1024 * 1024 // 1 MB
81
+
82
+ export interface UseAppearanceSettings {
83
+ loading: boolean
84
+ loadError: string | null
85
+ appearance: AppearanceForm
86
+ brand: BrandForm
87
+ /** Per-asset-id preview metadata (loaded + freshly uploaded). */
88
+ assets: Record<string, AssetPreview>
89
+ sources: Record<string, SettingsSourceMetadata>
90
+ issues: ValidationIssue[]
91
+ preview: { admin: ResolvedAdminBrand; public: ResolvedPublicBrand; faviconUrl: string | null }
92
+ dirty: boolean
93
+ saveState: SaveState
94
+ saveError: string | null
95
+ uploadingField: BrandAssetField | null
96
+ showAdvanced: boolean
97
+ setShowAdvanced: (v: boolean) => void
98
+ setColorMode: (v: ColorMode) => void
99
+ setAccent: (v: string) => void
100
+ setDensity: (v: SidebarDensity) => void
101
+ setFont: (v: string) => void
102
+ setReduceMotion: (v: boolean) => void
103
+ setBrandingMode: (v: AdminBrandingMode) => void
104
+ setAltText: (v: string) => void
105
+ uploadAsset: (field: BrandAssetField, file: File) => Promise<void>
106
+ removeAsset: (field: BrandAssetField) => void
107
+ save: () => Promise<void>
108
+ reset: () => void
109
+ reload: () => void
110
+ }
111
+
112
+ const DEFAULT_FORM: AppearanceForm = {
113
+ colorMode: 'system',
114
+ accent: 'purple',
115
+ density: 'default',
116
+ font: 'DM Sans',
117
+ reduceMotion: false,
118
+ }
119
+
120
+ const DEFAULT_BRAND_FORM: BrandForm = { mode: 'inheritPublicBrand' }
121
+
122
+ function snapshot(a: AppearanceForm, b: BrandForm): string {
123
+ return JSON.stringify({ a, b })
124
+ }
125
+
126
+ interface AppearanceApiResponse {
127
+ appearance: {
128
+ adminColorModeDefault: ColorMode
129
+ adminAccentColor: string
130
+ sidebarDensity: SidebarDensity
131
+ contentFont: string
132
+ reduceMotion: boolean
133
+ }
134
+ brand: BrandSettings
135
+ resolved: { admin: { initials: string } }
136
+ assets: BrandAssetLike[]
137
+ siteTitle: string | null
138
+ }
139
+
140
+ /** Read an uploaded image's shape client-side (raster via Image, SVG via XML). */
141
+ async function detectShape(file: File): Promise<LogoShape> {
142
+ if (file.type === 'image/svg+xml') {
143
+ try {
144
+ const text = await file.text()
145
+ const viewBox = text.match(/viewBox\s*=\s*["']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/i)
146
+ if (viewBox) return classifyLogoShape(parseFloat(viewBox[1]!), parseFloat(viewBox[2]!))
147
+ const w = text.match(/\bwidth\s*=\s*["']([\d.]+)/i)
148
+ const h = text.match(/\bheight\s*=\s*["']([\d.]+)/i)
149
+ if (w && h) return classifyLogoShape(parseFloat(w[1]!), parseFloat(h[1]!))
150
+ } catch {
151
+ /* fall through */
152
+ }
153
+ return 'unknown'
154
+ }
155
+ return new Promise<LogoShape>((resolve) => {
156
+ const url = URL.createObjectURL(file)
157
+ const img = new Image()
158
+ img.onload = () => {
159
+ URL.revokeObjectURL(url)
160
+ resolve(classifyLogoShape(img.naturalWidth, img.naturalHeight))
161
+ }
162
+ img.onerror = () => {
163
+ URL.revokeObjectURL(url)
164
+ resolve('unknown')
165
+ }
166
+ img.src = url
167
+ })
168
+ }
169
+
170
+ function assetToCache(
171
+ brand: BrandForm,
172
+ assets: Record<string, AssetPreview>,
173
+ siteTitle: string | null,
174
+ ): AdminBrandCache {
175
+ const map = new Map<string, BrandAssetLike>(
176
+ Object.values(assets).map((a) => [
177
+ a.id,
178
+ { id: a.id, publicUrl: a.url, mimeType: a.mimeType, width: a.width, height: a.height },
179
+ ]),
180
+ )
181
+ const resolved = resolveAdminBrand(brand as BrandSettings, map, siteTitle)
182
+ return {
183
+ primaryUrl: resolved.primary?.url ?? null,
184
+ markUrl: resolved.mark?.url ?? null,
185
+ darkUrl: resolved.dark?.url ?? null,
186
+ initials: resolved.initials,
187
+ }
188
+ }
189
+
190
+ export function useAppearanceSettings(
191
+ canEdit: boolean,
192
+ config?: Record<string, unknown>,
193
+ ): UseAppearanceSettings {
194
+ const { applyAppearance, setAdminBrand } = useTheme()
195
+
196
+ const [loading, setLoading] = useState(true)
197
+ const [loadError, setLoadError] = useState<string | null>(null)
198
+ const [appearance, setAppearance] = useState<AppearanceForm>(DEFAULT_FORM)
199
+ const [brand, setBrand] = useState<BrandForm>(DEFAULT_BRAND_FORM)
200
+ const [assets, setAssets] = useState<Record<string, AssetPreview>>({})
201
+ const [saveState, setSaveState] = useState<SaveState>('idle')
202
+ const [saveError, setSaveError] = useState<string | null>(null)
203
+ const [uploadingField, setUploadingField] = useState<BrandAssetField | null>(null)
204
+ const [showAdvanced, setShowAdvanced] = useState(false)
205
+
206
+ const baselineRef = useRef<string>(snapshot(DEFAULT_FORM, DEFAULT_BRAND_FORM))
207
+ const siteTitleRef = useRef<string | null>(null)
208
+
209
+ // Config-managed source detection (admin branding configured in actuate.config.ts).
210
+ const branding = (config?.admin as Record<string, unknown> | undefined)?.branding as
211
+ | Record<string, unknown>
212
+ | undefined
213
+ const sources = useMemo<Record<string, SettingsSourceMetadata>>(() => {
214
+ const out: Record<string, SettingsSourceMetadata> = {}
215
+ if (branding?.primaryColor) {
216
+ out.accent = {
217
+ source: 'config',
218
+ editable: false,
219
+ reason: 'Primary color is set in actuate.config.ts (admin.branding.primaryColor).',
220
+ }
221
+ }
222
+ if (branding?.darkMode !== undefined) {
223
+ out.colorMode = {
224
+ source: 'config',
225
+ editable: true,
226
+ reason: 'Default dark mode comes from admin.branding.darkMode; you can still override it.',
227
+ }
228
+ }
229
+ return out
230
+ }, [branding])
231
+
232
+ const pushLive = useCallback(
233
+ (a: AppearanceForm, b: BrandForm, assetMap: Record<string, AssetPreview>) => {
234
+ applyAppearance({
235
+ theme: a.colorMode,
236
+ accent: a.accent,
237
+ density: a.density,
238
+ font: a.font,
239
+ reduceMotion: a.reduceMotion,
240
+ adminBrand: assetToCache(b, assetMap, siteTitleRef.current),
241
+ })
242
+ },
243
+ [applyAppearance],
244
+ )
245
+
246
+ const load = useCallback(async () => {
247
+ setLoading(true)
248
+ setLoadError(null)
249
+ const res = await cmsApi<AppearanceApiResponse>('/appearance')
250
+ if (res.error || !res.data) {
251
+ setLoadError(res.error ?? 'Failed to load appearance settings')
252
+ setLoading(false)
253
+ return
254
+ }
255
+ const d = res.data
256
+ const nextAppearance: AppearanceForm = {
257
+ colorMode: d.appearance.adminColorModeDefault,
258
+ accent: d.appearance.adminAccentColor,
259
+ density: d.appearance.sidebarDensity,
260
+ font: d.appearance.contentFont,
261
+ reduceMotion: d.appearance.reduceMotion,
262
+ }
263
+ const nextBrand: BrandForm = { ...d.brand, mode: d.brand?.mode ?? 'inheritPublicBrand' }
264
+ const nextAssets: Record<string, AssetPreview> = {}
265
+ for (const a of d.assets ?? []) {
266
+ nextAssets[a.id] = {
267
+ id: a.id,
268
+ url: a.publicUrl,
269
+ mimeType: a.mimeType,
270
+ width: a.width ?? undefined,
271
+ height: a.height ?? undefined,
272
+ shape: classifyLogoShape(a.width ?? undefined, a.height ?? undefined),
273
+ }
274
+ }
275
+ siteTitleRef.current = d.siteTitle ?? null
276
+ setAppearance(nextAppearance)
277
+ setBrand(nextBrand)
278
+ setAssets(nextAssets)
279
+ if (nextBrand.adminLogoMarkAssetId || nextBrand.publicLogoMarkAssetId) setShowAdvanced(true)
280
+ baselineRef.current = snapshot(nextAppearance, nextBrand)
281
+ pushLive(nextAppearance, nextBrand, nextAssets)
282
+ setSaveState('idle')
283
+ setLoading(false)
284
+ }, [pushLive])
285
+
286
+ useEffect(() => {
287
+ void load()
288
+ }, [load])
289
+
290
+ // ── Mutators (live-apply theme controls before save) ──────────────────────
291
+ const update = useCallback(
292
+ (next: AppearanceForm, b: BrandForm, assetMap: Record<string, AssetPreview>) => {
293
+ setAppearance(next)
294
+ setBrand(b)
295
+ setAssets(assetMap)
296
+ setSaveState('idle')
297
+ pushLive(next, b, assetMap)
298
+ },
299
+ [pushLive],
300
+ )
301
+
302
+ const setColorMode = useCallback(
303
+ (v: ColorMode) => update({ ...appearance, colorMode: v }, brand, assets),
304
+ [appearance, brand, assets, update],
305
+ )
306
+ const setAccent = useCallback(
307
+ (v: string) => update({ ...appearance, accent: v }, brand, assets),
308
+ [appearance, brand, assets, update],
309
+ )
310
+ const setDensity = useCallback(
311
+ (v: SidebarDensity) => update({ ...appearance, density: v }, brand, assets),
312
+ [appearance, brand, assets, update],
313
+ )
314
+ const setFont = useCallback(
315
+ (v: string) => update({ ...appearance, font: v }, brand, assets),
316
+ [appearance, brand, assets, update],
317
+ )
318
+ const setReduceMotion = useCallback(
319
+ (v: boolean) => update({ ...appearance, reduceMotion: v }, brand, assets),
320
+ [appearance, brand, assets, update],
321
+ )
322
+ const setBrandingMode = useCallback(
323
+ (v: AdminBrandingMode) => update(appearance, { ...brand, mode: v }, assets),
324
+ [appearance, brand, assets, update],
325
+ )
326
+ const setAltText = useCallback(
327
+ (v: string) => update(appearance, { ...brand, defaultLogoAltText: v }, assets),
328
+ [appearance, brand, assets, update],
329
+ )
330
+
331
+ const uploadAsset = useCallback(
332
+ async (field: BrandAssetField, file: File) => {
333
+ const isFavicon = field === 'faviconSourceAssetId'
334
+ const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
335
+ if (!allowed.includes(file.type)) {
336
+ setSaveError(`Unsupported file type. Allowed: ${allowed.join(', ')}.`)
337
+ return
338
+ }
339
+ if (file.size > MAX_BYTES) {
340
+ setSaveError('File exceeds the 1MB limit.')
341
+ return
342
+ }
343
+ setSaveError(null)
344
+ setUploadingField(field)
345
+ try {
346
+ const shape = await detectShape(file)
347
+ const form = new FormData()
348
+ form.append('file', file)
349
+ // Preserve original format (SVG/PNG/ICO fidelity) — brand assets must
350
+ // not be transcoded to WebP.
351
+ form.append('skipOptimize', 'true')
352
+ const res = await cmsApi<{ id: string; url?: string; mimeType?: string }>('/media/upload', {
353
+ method: 'POST',
354
+ body: form,
355
+ })
356
+ if (res.error || !res.data?.id) {
357
+ setSaveError(res.error ?? 'Upload failed')
358
+ return
359
+ }
360
+ const id = res.data.id
361
+ const nextAssets: Record<string, AssetPreview> = {
362
+ ...assets,
363
+ [id]: {
364
+ id,
365
+ url: res.data.url ?? '',
366
+ mimeType: res.data.mimeType ?? file.type,
367
+ shape,
368
+ },
369
+ }
370
+ update(appearance, { ...brand, [field]: id }, nextAssets)
371
+ } finally {
372
+ setUploadingField(null)
373
+ }
374
+ },
375
+ [appearance, brand, assets, update],
376
+ )
377
+
378
+ const removeAsset = useCallback(
379
+ (field: BrandAssetField) => {
380
+ const next = { ...brand }
381
+ delete next[field]
382
+ update(appearance, next, assets)
383
+ },
384
+ [appearance, brand, assets, update],
385
+ )
386
+
387
+ const issues = useMemo<ValidationIssue[]>(() => {
388
+ const out: ValidationIssue[] = []
389
+ const accentCheck = validateAccentColor(appearance.accent)
390
+ if (!accentCheck.valid) {
391
+ out.push({
392
+ field: 'accent',
393
+ message: accentCheck.reason ?? 'Invalid accent color.',
394
+ severity: 'error',
395
+ })
396
+ }
397
+ // Shape warnings per asset field.
398
+ for (const field of Object.keys(FIELD_ROLE) as BrandAssetField[]) {
399
+ const id = brand[field]
400
+ if (!id) continue
401
+ const shape = assets[id]?.shape
402
+ if (!shape) continue
403
+ const w = logoShapeWarning(FIELD_ROLE[field], shape)
404
+ if (w) out.push({ field, message: w, severity: 'warning' })
405
+ }
406
+ // Dark mode default but no dark logo.
407
+ const hasDark = Boolean(brand.publicDarkLogoAssetId || brand.adminDarkLogoAssetId)
408
+ if (appearance.colorMode === 'dark' && !hasDark) {
409
+ out.push({
410
+ field: 'colorMode',
411
+ message:
412
+ 'Dark mode is the default but no dark-mode logo is set. The primary logo will be used on dark backgrounds.',
413
+ severity: 'warning',
414
+ })
415
+ }
416
+ return out
417
+ }, [appearance, brand, assets])
418
+
419
+ const preview = useMemo(() => {
420
+ const map = new Map<string, BrandAssetLike>(
421
+ Object.values(assets).map((a) => [
422
+ a.id,
423
+ { id: a.id, publicUrl: a.url, mimeType: a.mimeType, width: a.width, height: a.height },
424
+ ]),
425
+ )
426
+ const title = siteTitleRef.current
427
+ const faviconId = brand.faviconSourceAssetId
428
+ return {
429
+ admin: resolveAdminBrand(brand as BrandSettings, map, title),
430
+ public: resolvePublicBrand(brand as BrandSettings, map, title),
431
+ faviconUrl: faviconId ? (assets[faviconId]?.url ?? null) : null,
432
+ }
433
+ }, [brand, assets])
434
+
435
+ const dirty = useMemo(
436
+ () => snapshot(appearance, brand) !== baselineRef.current,
437
+ [appearance, brand],
438
+ )
439
+
440
+ const save = useCallback(async () => {
441
+ if (!canEdit) return
442
+ if (issues.some((i) => i.severity === 'error')) return
443
+ setSaveState('saving')
444
+ setSaveError(null)
445
+
446
+ // Read-modify-write the settings global so we never clobber sibling keys
447
+ // (siteTitle, SEO defaults, AI flags, …) written by other settings tabs.
448
+ const current = await cmsApi<Record<string, unknown>>('/globals/settings')
449
+ if (current.error) {
450
+ setSaveState('error')
451
+ setSaveError(current.error)
452
+ return
453
+ }
454
+ const now = new Date().toISOString()
455
+ const brandPayload: BrandSettings = { ...(brand as BrandSettings), updatedAt: now }
456
+ const appearancePayload = {
457
+ adminColorModeDefault: appearance.colorMode,
458
+ adminAccentColor: appearance.accent,
459
+ sidebarDensity: appearance.density,
460
+ contentFont: appearance.font,
461
+ reduceMotion: appearance.reduceMotion,
462
+ updatedAt: now,
463
+ }
464
+ const merged = {
465
+ ...(current.data ?? {}),
466
+ appearance: appearancePayload,
467
+ brand: brandPayload,
468
+ }
469
+ const res = await cmsApi('/globals/settings', { method: 'PUT', body: JSON.stringify(merged) })
470
+ if (res.error) {
471
+ setSaveState('error')
472
+ setSaveError(res.error)
473
+ return
474
+ }
475
+ baselineRef.current = snapshot(appearance, brand)
476
+ setAdminBrand(assetToCache(brand, assets, siteTitleRef.current))
477
+ setSaveState('success')
478
+ }, [canEdit, issues, appearance, brand, assets, setAdminBrand])
479
+
480
+ const reset = useCallback(() => {
481
+ const base = JSON.parse(baselineRef.current) as { a: AppearanceForm; b: BrandForm }
482
+ update(base.a, base.b, assets)
483
+ }, [assets, update])
484
+
485
+ return {
486
+ loading,
487
+ loadError,
488
+ appearance,
489
+ brand,
490
+ assets,
491
+ sources,
492
+ issues,
493
+ preview,
494
+ dirty,
495
+ saveState,
496
+ saveError,
497
+ uploadingField,
498
+ showAdvanced,
499
+ setShowAdvanced,
500
+ setColorMode,
501
+ setAccent,
502
+ setDensity,
503
+ setFont,
504
+ setReduceMotion,
505
+ setBrandingMode,
506
+ setAltText,
507
+ uploadAsset,
508
+ removeAsset,
509
+ save,
510
+ reset,
511
+ reload: () => void load(),
512
+ }
513
+ }
@@ -47,7 +47,10 @@ export interface SiteIndexStatus {
47
47
  }
48
48
 
49
49
  export interface ValidationIssue {
50
- field: keyof GeneralSettingsForm
50
+ /** Field key the issue relates to. A plain string so the primitive can be
51
+ * reused by other settings tabs (e.g. Appearance) whose fields aren't part
52
+ * of `GeneralSettingsForm`. */
53
+ field: string
51
54
  message: string
52
55
  severity: 'error' | 'warning'
53
56
  }