@actuate-media/cms-admin 0.25.0 → 0.27.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 (101) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +3 -3
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  7. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  8. package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/general-settings.render.test.js +105 -0
  11. package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/ThemeProvider.d.ts +47 -4
  14. package/dist/components/ThemeProvider.d.ts.map +1 -1
  15. package/dist/components/ThemeProvider.js +173 -27
  16. package/dist/components/ThemeProvider.js.map +1 -1
  17. package/dist/components/ui/Badge.d.ts +1 -1
  18. package/dist/components/ui/Button.d.ts +1 -1
  19. package/dist/components/ui/Card.d.ts +2 -2
  20. package/dist/components/ui/Input.d.ts +1 -1
  21. package/dist/components/ui/Select.d.ts +1 -1
  22. package/dist/layout/Sidebar.d.ts.map +1 -1
  23. package/dist/layout/Sidebar.js +47 -8
  24. package/dist/layout/Sidebar.js.map +1 -1
  25. package/dist/views/Settings.d.ts +3 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +10 -33
  28. package/dist/views/Settings.js.map +1 -1
  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/BrandPreviewCard.d.ts +5 -0
  38. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  39. package/dist/views/settings/BrandPreviewCard.js +18 -0
  40. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  41. package/dist/views/settings/BrandingCard.d.ts +6 -0
  42. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandingCard.js +38 -0
  44. package/dist/views/settings/BrandingCard.js.map +1 -0
  45. package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
  46. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
  47. package/dist/views/settings/GeneralSettingsTab.js +19 -0
  48. package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
  49. package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
  50. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
  51. package/dist/views/settings/LanguageRegionCard.js +48 -0
  52. package/dist/views/settings/LanguageRegionCard.js.map +1 -0
  53. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
  54. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
  55. package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
  56. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
  57. package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
  58. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
  59. package/dist/views/settings/SiteIndexStatusCard.js +51 -0
  60. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
  61. package/dist/views/settings/SiteInformationCard.d.ts +11 -0
  62. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
  63. package/dist/views/settings/SiteInformationCard.js +21 -0
  64. package/dist/views/settings/SiteInformationCard.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/components.d.ts +38 -0
  70. package/dist/views/settings/components.d.ts.map +1 -0
  71. package/dist/views/settings/components.js +43 -0
  72. package/dist/views/settings/components.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 +82 -0
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
  79. package/dist/views/settings/useGeneralSettings.js +286 -0
  80. package/dist/views/settings/useGeneralSettings.js.map +1 -0
  81. package/package.json +3 -3
  82. package/src/AdminRoot.tsx +5 -3
  83. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  84. package/src/__tests__/views/general-settings.render.test.tsx +135 -0
  85. package/src/components/ThemeProvider.tsx +249 -36
  86. package/src/layout/Sidebar.tsx +81 -13
  87. package/src/styles/theme.css +32 -2
  88. package/src/views/Settings.tsx +19 -155
  89. package/src/views/settings/AdminThemeCard.tsx +145 -0
  90. package/src/views/settings/AppearanceTab.tsx +81 -0
  91. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  92. package/src/views/settings/BrandingCard.tsx +332 -0
  93. package/src/views/settings/GeneralSettingsTab.tsx +126 -0
  94. package/src/views/settings/LanguageRegionCard.tsx +119 -0
  95. package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
  96. package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
  97. package/src/views/settings/SiteInformationCard.tsx +122 -0
  98. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  99. package/src/views/settings/components.tsx +198 -0
  100. package/src/views/settings/useAppearanceSettings.ts +513 -0
  101. package/src/views/settings/useGeneralSettings.ts +379 -0
@@ -0,0 +1,198 @@
1
+ 'use client'
2
+
3
+ import * as Dialog from '@radix-ui/react-dialog'
4
+ import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
5
+ import type { ReactNode } from 'react'
6
+ import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
7
+
8
+ const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
9
+ config: 'Config-managed',
10
+ environment: 'Environment-managed',
11
+ database: 'Editable',
12
+ plugin: 'Plugin setting',
13
+ computed: 'Computed',
14
+ }
15
+
16
+ /** Small badge showing where a setting's value comes from. */
17
+ export function SettingsSourceBadge({ meta }: { meta?: SettingsSourceMetadata }) {
18
+ if (!meta || (meta.source === 'database' && meta.editable)) return null
19
+ const locked = !meta.editable
20
+ return (
21
+ <span
22
+ className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs"
23
+ title={meta.reason}
24
+ >
25
+ {locked && <Lock size={11} aria-hidden="true" />}
26
+ {SOURCE_LABEL[meta.source]}
27
+ </span>
28
+ )
29
+ }
30
+
31
+ /** A labelled switch row with description, used for the SEO defaults. */
32
+ export function SettingsToggleRow({
33
+ label,
34
+ description,
35
+ checked,
36
+ onChange,
37
+ disabled = false,
38
+ badge,
39
+ }: {
40
+ label: string
41
+ description: string
42
+ checked: boolean
43
+ onChange: (value: boolean) => void
44
+ disabled?: boolean
45
+ badge?: ReactNode
46
+ }) {
47
+ return (
48
+ <div className="flex items-start justify-between gap-4">
49
+ <div className="flex-1">
50
+ <div className="flex items-center gap-2">
51
+ <span className="text-foreground text-base font-medium">{label}</span>
52
+ {badge}
53
+ </div>
54
+ <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>
55
+ </div>
56
+ <button
57
+ type="button"
58
+ role="switch"
59
+ aria-checked={checked}
60
+ aria-label={label}
61
+ disabled={disabled}
62
+ onClick={() => onChange(!checked)}
63
+ className={`focus-visible:ring-ring relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
64
+ checked ? 'bg-brand' : 'bg-switch-background'
65
+ }`}
66
+ >
67
+ <span
68
+ className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
69
+ checked ? 'translate-x-[22px]' : 'translate-x-0.5'
70
+ }`}
71
+ />
72
+ </button>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ /** Aggregated validation messages shown above the cards. */
78
+ export function SettingsValidationSummary({ issues }: { issues: ValidationIssue[] }) {
79
+ if (issues.length === 0) return null
80
+ const errors = issues.filter((i) => i.severity === 'error')
81
+ const warnings = issues.filter((i) => i.severity === 'warning')
82
+ const isError = errors.length > 0
83
+ return (
84
+ <div
85
+ role="alert"
86
+ aria-live="polite"
87
+ className={`flex items-start gap-3 rounded-lg border p-3 text-sm ${
88
+ isError
89
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
90
+ : 'border-warning/30 bg-warning/10 text-warning'
91
+ }`}
92
+ >
93
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
94
+ <div className="space-y-1">
95
+ {[...errors, ...warnings].map((issue, i) => (
96
+ <p key={`${issue.field}-${i}`}>{issue.message}</p>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ /** Sticky footer with Reset + Save, gated on dirty state. */
104
+ export function SettingsSaveBar({
105
+ dirty,
106
+ canEdit,
107
+ hasErrors,
108
+ saveState,
109
+ onSave,
110
+ onReset,
111
+ }: {
112
+ dirty: boolean
113
+ canEdit: boolean
114
+ hasErrors: boolean
115
+ saveState: 'idle' | 'saving' | 'success' | 'error'
116
+ onSave: () => void
117
+ onReset: () => void
118
+ }) {
119
+ const saving = saveState === 'saving'
120
+ return (
121
+ <div className="border-border bg-background/80 sticky bottom-0 z-10 flex items-center justify-end gap-4 border-t py-3 backdrop-blur">
122
+ {saveState === 'success' && !dirty && (
123
+ <span className="text-success flex items-center gap-1.5 text-sm" aria-live="polite">
124
+ <Check size={16} aria-hidden="true" /> Saved
125
+ </span>
126
+ )}
127
+ <button
128
+ type="button"
129
+ onClick={onReset}
130
+ disabled={!dirty || saving}
131
+ className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-base transition-colors disabled:cursor-not-allowed disabled:opacity-40"
132
+ >
133
+ Reset
134
+ </button>
135
+ <button
136
+ type="button"
137
+ onClick={onSave}
138
+ disabled={!canEdit || !dirty || hasErrors || saving}
139
+ className="bg-brand text-brand-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-5 py-2 text-base font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
140
+ >
141
+ {saving && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
142
+ {saving ? 'Saving…' : 'Save Changes'}
143
+ </button>
144
+ </div>
145
+ )
146
+ }
147
+
148
+ /** Confirmation dialog for dangerous indexing changes. */
149
+ export function ConfirmDangerousSettingDialog({
150
+ open,
151
+ title,
152
+ description,
153
+ confirmLabel = 'Continue',
154
+ onConfirm,
155
+ onCancel,
156
+ }: {
157
+ open: boolean
158
+ title: string
159
+ description: string
160
+ confirmLabel?: string
161
+ onConfirm: () => void
162
+ onCancel: () => void
163
+ }) {
164
+ return (
165
+ <Dialog.Root open={open} onOpenChange={(o) => !o && onCancel()}>
166
+ <Dialog.Portal>
167
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
168
+ <Dialog.Content className="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg">
169
+ <div className="mb-3 flex items-center gap-2">
170
+ <span className="bg-warning/15 text-warning flex h-9 w-9 items-center justify-center rounded-full">
171
+ <AlertTriangle size={20} aria-hidden="true" />
172
+ </span>
173
+ <Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
174
+ </div>
175
+ <Dialog.Description className="text-muted-foreground mb-5 text-sm">
176
+ {description}
177
+ </Dialog.Description>
178
+ <div className="flex items-center justify-end gap-3">
179
+ <button
180
+ type="button"
181
+ onClick={onCancel}
182
+ className="border-border text-foreground hover:bg-accent rounded-md border px-4 py-2 text-base transition-colors"
183
+ >
184
+ Cancel
185
+ </button>
186
+ <button
187
+ type="button"
188
+ onClick={onConfirm}
189
+ className="bg-destructive text-destructive-foreground rounded-md px-4 py-2 text-base font-medium transition-opacity hover:opacity-90"
190
+ >
191
+ {confirmLabel}
192
+ </button>
193
+ </div>
194
+ </Dialog.Content>
195
+ </Dialog.Portal>
196
+ </Dialog.Root>
197
+ )
198
+ }
@@ -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
+ }