@actuate-media/cms-admin 0.26.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/__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/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 +3 -7
- 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/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/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/package.json +2 -2
- package/src/__tests__/views/appearance-settings.render.test.tsx +132 -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 +3 -36
- 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/TypographyMotionCard.tsx +65 -0
- package/src/views/settings/useAppearanceSettings.ts +513 -0
- package/src/views/settings/useGeneralSettings.ts +4 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ACCENT_PRESET_LIST } from '@actuate-media/cms-core/appearance'
|
|
4
|
+
import type { ColorMode, SidebarDensity } from '@actuate-media/cms-core/appearance'
|
|
5
|
+
import { Check, Monitor, Moon, Sun } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { SettingsSourceBadge } from './components.js'
|
|
8
|
+
import type { UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
9
|
+
|
|
10
|
+
const COLOR_MODES: { value: ColorMode; label: string; Icon: typeof Sun }[] = [
|
|
11
|
+
{ value: 'light', label: 'Light', Icon: Sun },
|
|
12
|
+
{ value: 'dark', label: 'Dark', Icon: Moon },
|
|
13
|
+
{ value: 'system', label: 'System', Icon: Monitor },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const DENSITIES: { value: SidebarDensity; label: string }[] = [
|
|
17
|
+
{ value: 'default', label: 'Default' },
|
|
18
|
+
{ value: 'compact', label: 'Compact' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function SegmentedGroup<T extends string>({
|
|
22
|
+
label,
|
|
23
|
+
value,
|
|
24
|
+
options,
|
|
25
|
+
canEdit,
|
|
26
|
+
onChange,
|
|
27
|
+
}: {
|
|
28
|
+
label: string
|
|
29
|
+
value: T
|
|
30
|
+
options: { value: T; label: string; Icon?: typeof Sun }[]
|
|
31
|
+
canEdit: boolean
|
|
32
|
+
onChange: (v: T) => void
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
role="radiogroup"
|
|
37
|
+
aria-label={label}
|
|
38
|
+
className="border-border bg-muted inline-flex flex-wrap rounded-md border p-0.5"
|
|
39
|
+
>
|
|
40
|
+
{options.map(({ value: v, label: text, Icon }) => {
|
|
41
|
+
const active = value === v
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={v}
|
|
45
|
+
type="button"
|
|
46
|
+
role="radio"
|
|
47
|
+
aria-checked={active}
|
|
48
|
+
disabled={!canEdit}
|
|
49
|
+
onClick={() => onChange(v)}
|
|
50
|
+
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
51
|
+
active
|
|
52
|
+
? 'bg-card text-foreground shadow-sm'
|
|
53
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{Icon && <Icon size={14} aria-hidden="true" />}
|
|
57
|
+
{text}
|
|
58
|
+
</button>
|
|
59
|
+
)
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function AdminThemeCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
|
|
66
|
+
return (
|
|
67
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
68
|
+
<header className="mb-4">
|
|
69
|
+
<h3 className="text-foreground text-lg font-medium">Admin Theme</h3>
|
|
70
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
71
|
+
Controls the default look of the admin. Individual users can override the color mode.
|
|
72
|
+
</p>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="space-y-5">
|
|
76
|
+
<div>
|
|
77
|
+
<div className="mb-2 flex items-center gap-2">
|
|
78
|
+
<span className="text-foreground text-base font-medium">Color mode</span>
|
|
79
|
+
<SettingsSourceBadge meta={s.sources.colorMode} />
|
|
80
|
+
</div>
|
|
81
|
+
<SegmentedGroup
|
|
82
|
+
label="Color mode"
|
|
83
|
+
value={s.appearance.colorMode}
|
|
84
|
+
options={COLOR_MODES}
|
|
85
|
+
canEdit={canEdit}
|
|
86
|
+
onChange={s.setColorMode}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div>
|
|
91
|
+
<div className="mb-2 flex items-center gap-2">
|
|
92
|
+
<span className="text-foreground text-base font-medium">Accent color</span>
|
|
93
|
+
<SettingsSourceBadge meta={s.sources.accent} />
|
|
94
|
+
</div>
|
|
95
|
+
<div
|
|
96
|
+
role="radiogroup"
|
|
97
|
+
aria-label="Accent color"
|
|
98
|
+
className="flex flex-wrap items-center gap-3"
|
|
99
|
+
>
|
|
100
|
+
{ACCENT_PRESET_LIST.map((preset) => {
|
|
101
|
+
const active = s.appearance.accent === preset.name
|
|
102
|
+
const editable = s.sources.accent?.editable !== false
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
key={preset.name}
|
|
106
|
+
type="button"
|
|
107
|
+
role="radio"
|
|
108
|
+
aria-checked={active}
|
|
109
|
+
aria-label={preset.label}
|
|
110
|
+
title={preset.label}
|
|
111
|
+
disabled={!canEdit || !editable}
|
|
112
|
+
onClick={() => s.setAccent(preset.name)}
|
|
113
|
+
className={`focus-visible:ring-ring relative flex h-9 w-9 items-center justify-center rounded-full transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
114
|
+
active ? 'ring-foreground ring-2 ring-offset-2' : ''
|
|
115
|
+
}`}
|
|
116
|
+
style={{ backgroundColor: preset.swatch }}
|
|
117
|
+
>
|
|
118
|
+
{active && <Check size={16} className="text-white" aria-hidden="true" />}
|
|
119
|
+
</button>
|
|
120
|
+
)
|
|
121
|
+
})}
|
|
122
|
+
{/* Text label so accent is not communicated by color alone. */}
|
|
123
|
+
<span className="text-muted-foreground text-sm">
|
|
124
|
+
{ACCENT_PRESET_LIST.find((p) => p.name === s.appearance.accent)?.label ??
|
|
125
|
+
s.appearance.accent}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div>
|
|
131
|
+
<div className="mb-2 flex items-center gap-2">
|
|
132
|
+
<span className="text-foreground text-base font-medium">Sidebar density</span>
|
|
133
|
+
</div>
|
|
134
|
+
<SegmentedGroup
|
|
135
|
+
label="Sidebar density"
|
|
136
|
+
value={s.appearance.density}
|
|
137
|
+
options={DENSITIES}
|
|
138
|
+
canEdit={canEdit}
|
|
139
|
+
onChange={s.setDensity}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { AdminThemeCard } from './AdminThemeCard.js'
|
|
6
|
+
import { BrandPreviewCard } from './BrandPreviewCard.js'
|
|
7
|
+
import { BrandingCard } from './BrandingCard.js'
|
|
8
|
+
import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
|
|
9
|
+
import { TypographyMotionCard } from './TypographyMotionCard.js'
|
|
10
|
+
import { useAppearanceSettings } from './useAppearanceSettings.js'
|
|
11
|
+
|
|
12
|
+
export interface AppearanceTabProps {
|
|
13
|
+
/** Current user's role. Only ADMIN can persist changes. */
|
|
14
|
+
role?: string
|
|
15
|
+
config?: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardSkeleton() {
|
|
19
|
+
return <div className="border-border bg-card h-48 animate-pulse rounded-lg border" />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function AppearanceTab({ role, config }: AppearanceTabProps) {
|
|
23
|
+
const canEdit = role === 'ADMIN'
|
|
24
|
+
const s = useAppearanceSettings(canEdit, config)
|
|
25
|
+
|
|
26
|
+
if (s.loading) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-4" aria-busy="true">
|
|
29
|
+
<CardSkeleton />
|
|
30
|
+
<CardSkeleton />
|
|
31
|
+
<CardSkeleton />
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (s.loadError) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
role="alert"
|
|
40
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4 rounded-lg border p-4 text-sm"
|
|
41
|
+
>
|
|
42
|
+
<span className="flex items-center gap-2">
|
|
43
|
+
<AlertTriangle size={16} aria-hidden="true" /> {s.loadError}
|
|
44
|
+
</span>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={s.reload}
|
|
48
|
+
className="border-destructive/40 text-destructive hover:bg-destructive/10 inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5"
|
|
49
|
+
>
|
|
50
|
+
<Loader2 size={14} aria-hidden="true" /> Retry
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
{!canEdit && (
|
|
59
|
+
<p className="text-muted-foreground border-border bg-muted rounded-lg border p-3 text-sm">
|
|
60
|
+
You have read-only access to appearance settings.
|
|
61
|
+
</p>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<SettingsValidationSummary issues={s.issues} />
|
|
65
|
+
|
|
66
|
+
<BrandingCard s={s} canEdit={canEdit} />
|
|
67
|
+
<AdminThemeCard s={s} canEdit={canEdit} />
|
|
68
|
+
<TypographyMotionCard s={s} canEdit={canEdit} />
|
|
69
|
+
<BrandPreviewCard s={s} />
|
|
70
|
+
|
|
71
|
+
<SettingsSaveBar
|
|
72
|
+
dirty={s.dirty}
|
|
73
|
+
canEdit={canEdit}
|
|
74
|
+
hasErrors={s.issues.some((i) => i.severity === 'error')}
|
|
75
|
+
saveState={s.saveState}
|
|
76
|
+
onSave={s.save}
|
|
77
|
+
onReset={s.reset}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedAdminBrand } from '@actuate-media/cms-core/appearance'
|
|
4
|
+
|
|
5
|
+
import type { UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
6
|
+
|
|
7
|
+
function Initials({ initials, size = 'h-8 w-8 text-sm' }: { initials: string; size?: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
className={`bg-brand text-brand-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${size}`}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
>
|
|
13
|
+
{initials}
|
|
14
|
+
</span>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expanded sidebar lockup preview (primary → mark+title → initials). */
|
|
19
|
+
function SidebarPreview({ admin, dark }: { admin: ResolvedAdminBrand; dark?: boolean }) {
|
|
20
|
+
const logo = dark && admin.dark ? admin.dark : admin.primary
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={`flex h-14 items-center gap-2.5 rounded-md border px-3 ${
|
|
24
|
+
dark ? 'border-border bg-foreground' : 'bg-sidebar border-sidebar-border'
|
|
25
|
+
}`}
|
|
26
|
+
>
|
|
27
|
+
{logo ? (
|
|
28
|
+
<img src={logo.url} alt={logo.alt} className="h-8 w-auto object-contain" />
|
|
29
|
+
) : admin.mark ? (
|
|
30
|
+
<>
|
|
31
|
+
<img src={admin.mark.url} alt={admin.mark.alt} className="h-8 w-8 object-contain" />
|
|
32
|
+
<span className={dark ? 'text-background text-sm' : 'text-sidebar-foreground text-sm'}>
|
|
33
|
+
{admin.initials}
|
|
34
|
+
</span>
|
|
35
|
+
</>
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
<Initials initials={admin.initials} />
|
|
39
|
+
<span className={dark ? 'text-background text-sm' : 'text-sidebar-foreground text-sm'}>
|
|
40
|
+
Admin
|
|
41
|
+
</span>
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function PreviewTile({ label, children }: { label: string; children: React.ReactNode }) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-1.5">
|
|
51
|
+
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function BrandPreviewCard({ s }: { s: UseAppearanceSettings }) {
|
|
58
|
+
const { admin, public: pub, faviconUrl } = s.preview
|
|
59
|
+
return (
|
|
60
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
61
|
+
<header className="mb-4 flex items-center justify-between gap-2">
|
|
62
|
+
<div>
|
|
63
|
+
<h3 className="text-foreground text-lg font-medium">Brand Preview</h3>
|
|
64
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
65
|
+
How your brand renders across the admin and public site.
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
{s.dirty && (
|
|
69
|
+
<span className="text-warning bg-warning/10 border-warning/30 rounded-md border px-2 py-0.5 text-xs">
|
|
70
|
+
Unsaved
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
76
|
+
<PreviewTile label="Admin sidebar">
|
|
77
|
+
<SidebarPreview admin={admin} />
|
|
78
|
+
</PreviewTile>
|
|
79
|
+
|
|
80
|
+
<PreviewTile label="Collapsed sidebar">
|
|
81
|
+
<div className="bg-sidebar border-sidebar-border flex h-14 w-14 items-center justify-center rounded-md border">
|
|
82
|
+
{admin.mark ? (
|
|
83
|
+
<img src={admin.mark.url} alt={admin.mark.alt} className="h-8 w-8 object-contain" />
|
|
84
|
+
) : (
|
|
85
|
+
<Initials initials={admin.initials} />
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</PreviewTile>
|
|
89
|
+
|
|
90
|
+
<PreviewTile label="Dark mode">
|
|
91
|
+
<SidebarPreview admin={admin} dark />
|
|
92
|
+
</PreviewTile>
|
|
93
|
+
|
|
94
|
+
<PreviewTile label="Public header">
|
|
95
|
+
<div className="border-border bg-background flex h-14 items-center gap-2.5 rounded-md border px-3">
|
|
96
|
+
{pub.primary ? (
|
|
97
|
+
<img
|
|
98
|
+
src={pub.primary.url}
|
|
99
|
+
alt={pub.primary.alt}
|
|
100
|
+
className="h-8 w-auto object-contain"
|
|
101
|
+
/>
|
|
102
|
+
) : pub.mark ? (
|
|
103
|
+
<>
|
|
104
|
+
<img src={pub.mark.url} alt={pub.mark.alt} className="h-8 w-8 object-contain" />
|
|
105
|
+
<span className="text-foreground text-sm">{pub.initials}</span>
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<span className="text-foreground text-sm font-medium">{pub.initials}</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</PreviewTile>
|
|
112
|
+
|
|
113
|
+
<PreviewTile label="Favicon">
|
|
114
|
+
<div className="border-border bg-muted flex h-14 w-14 items-center justify-center rounded-md border p-2">
|
|
115
|
+
{faviconUrl ? (
|
|
116
|
+
<img src={faviconUrl} alt="Favicon preview" className="h-8 w-8 object-contain" />
|
|
117
|
+
) : (
|
|
118
|
+
<span className="text-muted-foreground text-xs">None</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</PreviewTile>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
ChevronDown,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
ImageIcon,
|
|
8
|
+
Loader2,
|
|
9
|
+
Trash2,
|
|
10
|
+
Upload,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import { useRef } from 'react'
|
|
13
|
+
|
|
14
|
+
import type { BrandAssetField, UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
15
|
+
|
|
16
|
+
const LOGO_ACCEPT = 'image/svg+xml,image/png'
|
|
17
|
+
|
|
18
|
+
function previewSurface(dark: boolean): string {
|
|
19
|
+
return dark ? 'bg-foreground border-border' : 'bg-muted border-border'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function LogoField({
|
|
23
|
+
label,
|
|
24
|
+
helper,
|
|
25
|
+
field,
|
|
26
|
+
accept,
|
|
27
|
+
square,
|
|
28
|
+
darkSurface,
|
|
29
|
+
brand,
|
|
30
|
+
assets,
|
|
31
|
+
uploadingField,
|
|
32
|
+
canEdit,
|
|
33
|
+
warning,
|
|
34
|
+
onUpload,
|
|
35
|
+
onRemove,
|
|
36
|
+
}: {
|
|
37
|
+
label: string
|
|
38
|
+
helper: string
|
|
39
|
+
field: BrandAssetField
|
|
40
|
+
accept: string
|
|
41
|
+
square?: boolean
|
|
42
|
+
darkSurface?: boolean
|
|
43
|
+
brand: UseAppearanceSettings['brand']
|
|
44
|
+
assets: UseAppearanceSettings['assets']
|
|
45
|
+
uploadingField: BrandAssetField | null
|
|
46
|
+
canEdit: boolean
|
|
47
|
+
warning?: string
|
|
48
|
+
onUpload: (field: BrandAssetField, file: File) => void
|
|
49
|
+
onRemove: (field: BrandAssetField) => void
|
|
50
|
+
}) {
|
|
51
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
52
|
+
const assetId = brand[field]
|
|
53
|
+
const asset = assetId ? assets[assetId] : undefined
|
|
54
|
+
const uploading = uploadingField === field
|
|
55
|
+
const inputId = `brand-${field}`
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex items-start gap-4">
|
|
59
|
+
<div
|
|
60
|
+
className={`flex shrink-0 items-center justify-center overflow-hidden rounded-md border p-2 ${previewSurface(
|
|
61
|
+
Boolean(darkSurface),
|
|
62
|
+
)} ${square ? 'h-12 w-12' : 'h-12 w-20'}`}
|
|
63
|
+
aria-hidden={!asset}
|
|
64
|
+
>
|
|
65
|
+
{asset?.url ? (
|
|
66
|
+
// Never stretch/crop — contain preserves aspect ratio + transparency.
|
|
67
|
+
<img
|
|
68
|
+
src={asset.url}
|
|
69
|
+
alt={`${label} preview`}
|
|
70
|
+
className="max-h-full max-w-full object-contain"
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
<ImageIcon size={18} className="text-muted-foreground" aria-hidden="true" />
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="min-w-0 flex-1">
|
|
78
|
+
<label htmlFor={inputId} className="text-foreground block text-base font-medium">
|
|
79
|
+
{label}
|
|
80
|
+
</label>
|
|
81
|
+
<p className="text-muted-foreground mt-0.5 text-sm">{helper}</p>
|
|
82
|
+
|
|
83
|
+
<div className="mt-2 flex items-center gap-2">
|
|
84
|
+
<input
|
|
85
|
+
ref={inputRef}
|
|
86
|
+
id={inputId}
|
|
87
|
+
type="file"
|
|
88
|
+
accept={accept}
|
|
89
|
+
className="sr-only"
|
|
90
|
+
disabled={!canEdit || uploading}
|
|
91
|
+
onChange={(e) => {
|
|
92
|
+
const file = e.target.files?.[0]
|
|
93
|
+
if (file) onUpload(field, file)
|
|
94
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => inputRef.current?.click()}
|
|
100
|
+
disabled={!canEdit || uploading}
|
|
101
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
102
|
+
>
|
|
103
|
+
{uploading ? (
|
|
104
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
105
|
+
) : (
|
|
106
|
+
<Upload size={14} aria-hidden="true" />
|
|
107
|
+
)}
|
|
108
|
+
{asset ? 'Replace' : `Upload ${label.toLowerCase()}`}
|
|
109
|
+
</button>
|
|
110
|
+
{asset && (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => onRemove(field)}
|
|
114
|
+
disabled={!canEdit}
|
|
115
|
+
className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
116
|
+
aria-label={`Remove ${label}`}
|
|
117
|
+
>
|
|
118
|
+
<Trash2 size={14} aria-hidden="true" /> Remove
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{warning && (
|
|
124
|
+
<p
|
|
125
|
+
className="text-warning mt-2 flex items-start gap-1.5 text-sm"
|
|
126
|
+
role="status"
|
|
127
|
+
aria-live="polite"
|
|
128
|
+
>
|
|
129
|
+
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" /> {warning}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
|
|
138
|
+
const warningFor = (field: BrandAssetField): string | undefined =>
|
|
139
|
+
s.issues.find((i) => i.field === field && i.severity === 'warning')?.message
|
|
140
|
+
|
|
141
|
+
const custom = s.brand.mode === 'customAdminBrand'
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
145
|
+
<header className="mb-4">
|
|
146
|
+
<h3 className="text-foreground text-lg font-medium">Branding</h3>
|
|
147
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
148
|
+
Logos shown in the admin sidebar and on the public site.
|
|
149
|
+
</p>
|
|
150
|
+
</header>
|
|
151
|
+
|
|
152
|
+
<div className="space-y-5">
|
|
153
|
+
<LogoField
|
|
154
|
+
label="Primary Logo"
|
|
155
|
+
helper="SVG or PNG, up to 1MB. Best for the public header and full admin sidebar."
|
|
156
|
+
field="publicPrimaryLogoAssetId"
|
|
157
|
+
accept={LOGO_ACCEPT}
|
|
158
|
+
brand={s.brand}
|
|
159
|
+
assets={s.assets}
|
|
160
|
+
uploadingField={s.uploadingField}
|
|
161
|
+
canEdit={canEdit}
|
|
162
|
+
warning={warningFor('publicPrimaryLogoAssetId')}
|
|
163
|
+
onUpload={s.uploadAsset}
|
|
164
|
+
onRemove={s.removeAsset}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
<LogoField
|
|
168
|
+
label="Favicon / App Icon Source"
|
|
169
|
+
helper="32×32 PNG or SVG. A larger square image is fine — it scales down cleanly."
|
|
170
|
+
field="faviconSourceAssetId"
|
|
171
|
+
accept={LOGO_ACCEPT}
|
|
172
|
+
square
|
|
173
|
+
brand={s.brand}
|
|
174
|
+
assets={s.assets}
|
|
175
|
+
uploadingField={s.uploadingField}
|
|
176
|
+
canEdit={canEdit}
|
|
177
|
+
warning={warningFor('faviconSourceAssetId')}
|
|
178
|
+
onUpload={s.uploadAsset}
|
|
179
|
+
onRemove={s.removeAsset}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
{/* Advanced logo variants disclosure (Logo Mark + Dark Mode Logo). */}
|
|
183
|
+
<div className="border-border border-t pt-4">
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={() => s.setShowAdvanced(!s.showAdvanced)}
|
|
187
|
+
aria-expanded={s.showAdvanced}
|
|
188
|
+
className="text-foreground hover:text-brand inline-flex items-center gap-1.5 text-sm font-medium transition-colors"
|
|
189
|
+
>
|
|
190
|
+
{s.showAdvanced ? (
|
|
191
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
192
|
+
) : (
|
|
193
|
+
<ChevronRight size={16} aria-hidden="true" />
|
|
194
|
+
)}
|
|
195
|
+
Advanced logo variants
|
|
196
|
+
</button>
|
|
197
|
+
|
|
198
|
+
{s.showAdvanced && (
|
|
199
|
+
<div className="mt-4 space-y-5">
|
|
200
|
+
<LogoField
|
|
201
|
+
label="Logo Mark"
|
|
202
|
+
helper="Square icon or mark. Used for the collapsed sidebar, compact UI, and app icons."
|
|
203
|
+
field="publicLogoMarkAssetId"
|
|
204
|
+
accept={LOGO_ACCEPT}
|
|
205
|
+
square
|
|
206
|
+
brand={s.brand}
|
|
207
|
+
assets={s.assets}
|
|
208
|
+
uploadingField={s.uploadingField}
|
|
209
|
+
canEdit={canEdit}
|
|
210
|
+
warning={warningFor('publicLogoMarkAssetId')}
|
|
211
|
+
onUpload={s.uploadAsset}
|
|
212
|
+
onRemove={s.removeAsset}
|
|
213
|
+
/>
|
|
214
|
+
<LogoField
|
|
215
|
+
label="Dark Mode Logo"
|
|
216
|
+
helper="Optional. Used on dark backgrounds or the dark admin theme."
|
|
217
|
+
field="publicDarkLogoAssetId"
|
|
218
|
+
accept={LOGO_ACCEPT}
|
|
219
|
+
darkSurface
|
|
220
|
+
brand={s.brand}
|
|
221
|
+
assets={s.assets}
|
|
222
|
+
uploadingField={s.uploadingField}
|
|
223
|
+
canEdit={canEdit}
|
|
224
|
+
warning={warningFor('publicDarkLogoAssetId')}
|
|
225
|
+
onUpload={s.uploadAsset}
|
|
226
|
+
onRemove={s.removeAsset}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Admin branding mode — admin inherits the public brand by default. */}
|
|
233
|
+
<div className="border-border border-t pt-4">
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<span className="text-foreground text-base font-medium">Admin branding</span>
|
|
236
|
+
</div>
|
|
237
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
238
|
+
Admin uses the public brand by default.
|
|
239
|
+
</p>
|
|
240
|
+
<div
|
|
241
|
+
role="radiogroup"
|
|
242
|
+
aria-label="Admin branding mode"
|
|
243
|
+
className="border-border bg-muted mt-2 inline-flex rounded-md border p-0.5"
|
|
244
|
+
>
|
|
245
|
+
{(
|
|
246
|
+
[
|
|
247
|
+
['inheritPublicBrand', 'Inherit public brand'],
|
|
248
|
+
['customAdminBrand', 'Custom admin logo'],
|
|
249
|
+
] as const
|
|
250
|
+
).map(([value, text]) => {
|
|
251
|
+
const active = s.brand.mode === value
|
|
252
|
+
return (
|
|
253
|
+
<button
|
|
254
|
+
key={value}
|
|
255
|
+
type="button"
|
|
256
|
+
role="radio"
|
|
257
|
+
aria-checked={active}
|
|
258
|
+
disabled={!canEdit}
|
|
259
|
+
onClick={() => s.setBrandingMode(value)}
|
|
260
|
+
className={`rounded px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
261
|
+
active
|
|
262
|
+
? 'bg-card text-foreground shadow-sm'
|
|
263
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
264
|
+
}`}
|
|
265
|
+
>
|
|
266
|
+
{text}
|
|
267
|
+
</button>
|
|
268
|
+
)
|
|
269
|
+
})}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{custom && (
|
|
273
|
+
<div className="mt-4 space-y-5">
|
|
274
|
+
<LogoField
|
|
275
|
+
label="Admin Primary Logo"
|
|
276
|
+
helper="Shown in the admin sidebar instead of the public logo."
|
|
277
|
+
field="adminPrimaryLogoAssetId"
|
|
278
|
+
accept={LOGO_ACCEPT}
|
|
279
|
+
brand={s.brand}
|
|
280
|
+
assets={s.assets}
|
|
281
|
+
uploadingField={s.uploadingField}
|
|
282
|
+
canEdit={canEdit}
|
|
283
|
+
warning={warningFor('adminPrimaryLogoAssetId')}
|
|
284
|
+
onUpload={s.uploadAsset}
|
|
285
|
+
onRemove={s.removeAsset}
|
|
286
|
+
/>
|
|
287
|
+
<LogoField
|
|
288
|
+
label="Admin Logo Mark"
|
|
289
|
+
helper="Square mark for the collapsed admin sidebar."
|
|
290
|
+
field="adminLogoMarkAssetId"
|
|
291
|
+
accept={LOGO_ACCEPT}
|
|
292
|
+
square
|
|
293
|
+
brand={s.brand}
|
|
294
|
+
assets={s.assets}
|
|
295
|
+
uploadingField={s.uploadingField}
|
|
296
|
+
canEdit={canEdit}
|
|
297
|
+
warning={warningFor('adminLogoMarkAssetId')}
|
|
298
|
+
onUpload={s.uploadAsset}
|
|
299
|
+
onRemove={s.removeAsset}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{/* Default alt text */}
|
|
306
|
+
<div className="border-border border-t pt-4">
|
|
307
|
+
<label htmlFor="brand-alt" className="text-foreground block text-base font-medium">
|
|
308
|
+
Logo alt text
|
|
309
|
+
</label>
|
|
310
|
+
<p className="text-muted-foreground mt-0.5 mb-2 text-sm">
|
|
311
|
+
Used by screen readers on the public site. Defaults to the Site Title when empty.
|
|
312
|
+
</p>
|
|
313
|
+
<input
|
|
314
|
+
id="brand-alt"
|
|
315
|
+
type="text"
|
|
316
|
+
value={s.brand.defaultLogoAltText ?? ''}
|
|
317
|
+
disabled={!canEdit}
|
|
318
|
+
onChange={(e) => s.setAltText(e.target.value)}
|
|
319
|
+
placeholder="Site Title"
|
|
320
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
{s.saveError && (
|
|
326
|
+
<p className="text-destructive mt-4 text-sm" role="alert">
|
|
327
|
+
{s.saveError}
|
|
328
|
+
</p>
|
|
329
|
+
)}
|
|
330
|
+
</section>
|
|
331
|
+
)
|
|
332
|
+
}
|