@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
|
@@ -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
|
-
|
|
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
|
}
|