@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -3
- package/dist/AdminRoot.js.map +1 -1
- 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/general-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/general-settings.render.test.js +105 -0
- package/dist/__tests__/views/general-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 +3 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +10 -33
- package/dist/views/Settings.js.map +1 -1
- 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/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/GeneralSettingsTab.d.ts +7 -0
- package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/GeneralSettingsTab.js +19 -0
- package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
- package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
- package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
- package/dist/views/settings/LanguageRegionCard.js +48 -0
- package/dist/views/settings/LanguageRegionCard.js.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.js +51 -0
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
- package/dist/views/settings/SiteInformationCard.d.ts +11 -0
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
- package/dist/views/settings/SiteInformationCard.js +21 -0
- package/dist/views/settings/SiteInformationCard.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/components.d.ts +38 -0
- package/dist/views/settings/components.d.ts.map +1 -0
- package/dist/views/settings/components.js +43 -0
- package/dist/views/settings/components.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 +82 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
- package/dist/views/settings/useGeneralSettings.js +286 -0
- package/dist/views/settings/useGeneralSettings.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +5 -3
- package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
- package/src/__tests__/views/general-settings.render.test.tsx +135 -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 +19 -155
- package/src/views/settings/AdminThemeCard.tsx +145 -0
- package/src/views/settings/AppearanceTab.tsx +81 -0
- package/src/views/settings/BrandPreviewCard.tsx +125 -0
- package/src/views/settings/BrandingCard.tsx +332 -0
- package/src/views/settings/GeneralSettingsTab.tsx +126 -0
- package/src/views/settings/LanguageRegionCard.tsx +119 -0
- package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
- package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
- package/src/views/settings/SiteInformationCard.tsx +122 -0
- package/src/views/settings/TypographyMotionCard.tsx +65 -0
- package/src/views/settings/components.tsx +198 -0
- package/src/views/settings/useAppearanceSettings.ts +513 -0
- 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
|
+
}
|