@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
@@ -10,7 +10,6 @@ import {
10
10
  BookOpen,
11
11
  Sparkles,
12
12
  MessageSquare,
13
- Languages,
14
13
  Loader2,
15
14
  AlertTriangle,
16
15
  Download,
@@ -27,39 +26,39 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
27
26
  import { toast } from 'sonner'
28
27
  import { useApiData } from '../lib/useApiData.js'
29
28
  import { cmsApi } from '../lib/api.js'
30
- import { useTheme } from '../components/ThemeProvider.js'
31
29
  import { RelationshipField } from '../fields/RelationshipField.js'
32
30
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
31
+ import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
32
+ import { AppearanceTab } from './settings/AppearanceTab.js'
33
33
  import { Users as UsersView } from './Users.js'
34
34
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
35
35
 
36
36
  export interface SettingsProps {
37
37
  onNavigate?: (path: string) => void
38
38
  config?: any
39
+ /** Current admin session — used to gate editing on role. */
40
+ session?: any
39
41
  /** Initial tab when arriving without a `?tab=` query param (e.g. via the
40
42
  * legacy `/users` or `/api-keys` routes). */
41
43
  initialTab?: string
42
44
  }
43
45
 
44
46
  /** Tabs that drive their own persistence (no global "Save Changes" footer). */
45
- const SELF_MANAGED_TABS = new Set(['users', 'api-keys'])
47
+ const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'users', 'api-keys'])
46
48
 
47
49
  function readSettingsTabFromUrl(): string | null {
48
50
  if (typeof window === 'undefined') return null
49
51
  return new URLSearchParams(window.location.search).get('tab')
50
52
  }
51
53
 
52
- export function Settings({ config, onNavigate, initialTab = 'general' }: SettingsProps = {}) {
54
+ export function Settings({
55
+ config,
56
+ onNavigate,
57
+ session,
58
+ initialTab = 'general',
59
+ }: SettingsProps = {}) {
53
60
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
54
61
 
55
- const [siteTitle, setSiteTitle] = useState('My CMS')
56
- const [tagline, setTagline] = useState('A lightweight content management system')
57
- const [siteUrl, setSiteUrl] = useState('https://example.com')
58
- const [language, setLanguage] = useState('en')
59
- const [timezone, setTimezone] = useState('UTC')
60
- const [defaultNoIndex, setDefaultNoIndex] = useState(false)
61
- const [defaultNoFollow, setDefaultNoFollow] = useState(false)
62
- const [noIndexNonProduction, setNoIndexNonProduction] = useState(false)
63
62
  const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
64
63
  const [sessionTimeout, setSessionTimeout] = useState(false)
65
64
  const [ipWhitelist, setIpWhitelist] = useState(false)
@@ -139,14 +138,6 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
139
138
 
140
139
  useEffect(() => {
141
140
  if (data) {
142
- setSiteTitle(data.siteTitle ?? 'My CMS')
143
- setTagline(data.tagline ?? '')
144
- setSiteUrl(data.siteUrl ?? '')
145
- setLanguage(data.language ?? 'en')
146
- setTimezone(data.timezone ?? 'UTC')
147
- setDefaultNoIndex(data.defaultNoIndex ?? false)
148
- setDefaultNoFollow(data.defaultNoFollow ?? false)
149
- setNoIndexNonProduction(data.noIndexNonProduction ?? false)
150
141
  setTwoFactorEnabled(data.twoFactorEnabled ?? false)
151
142
  setSessionTimeout(data.sessionTimeout ?? false)
152
143
  setIpWhitelist(data.ipWhitelist ?? false)
@@ -169,17 +160,13 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
169
160
  const handleSave = async () => {
170
161
  setSaving(true)
171
162
  const layoutPayload = Object.keys(defaultLayout).length > 0 ? { defaultLayout } : {}
163
+ // Read-modify-write: the General tab owns site info / SEO defaults in the
164
+ // same global blob, so merge against a fresh fetch instead of overwriting.
165
+ const current = await cmsApi<Record<string, unknown>>('/globals/settings')
172
166
  const res = await cmsApi('/globals/settings', {
173
167
  method: 'PUT',
174
168
  body: JSON.stringify({
175
- siteTitle,
176
- tagline,
177
- siteUrl,
178
- language,
179
- timezone,
180
- defaultNoIndex,
181
- defaultNoFollow,
182
- noIndexNonProduction,
169
+ ...(current.data ?? {}),
183
170
  twoFactorEnabled,
184
171
  sessionTimeout,
185
172
  ipWhitelist,
@@ -205,7 +192,7 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
205
192
  }
206
193
 
207
194
  const tabTriggerClass =
208
- 'px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600'
195
+ 'text-muted-foreground hover:text-foreground data-[state=active]:border-brand data-[state=active]:text-brand px-4 py-2 text-base font-medium transition-colors data-[state=active]:border-b-2'
209
196
 
210
197
  if (loading) {
211
198
  return (
@@ -283,120 +270,12 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
283
270
  </Tabs.Trigger>
284
271
  </Tabs.List>
285
272
 
286
- <Tabs.Content value="general" className="space-y-4">
287
- <div className="rounded-lg border border-gray-200 bg-white p-4">
288
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Site Information</h3>
289
- <div className="space-y-4">
290
- <div>
291
- <label className="mb-1 block text-sm font-medium text-gray-700">Site Title</label>
292
- <input
293
- type="text"
294
- value={siteTitle}
295
- onChange={(e) => setSiteTitle(e.target.value)}
296
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
297
- />
298
- </div>
299
- <div>
300
- <label className="mb-1 block text-sm font-medium text-gray-700">Tagline</label>
301
- <input
302
- type="text"
303
- value={tagline}
304
- onChange={(e) => setTagline(e.target.value)}
305
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
306
- />
307
- </div>
308
- <div>
309
- <label className="mb-1 block text-sm font-medium text-gray-700">Site URL</label>
310
- <input
311
- type="url"
312
- value={siteUrl}
313
- onChange={(e) => setSiteUrl(e.target.value)}
314
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
315
- />
316
- </div>
317
- </div>
318
- </div>
319
- <div className="rounded-lg border border-gray-200 bg-white p-4">
320
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Language & Region</h3>
321
- <div className="space-y-4">
322
- <div>
323
- <label className="mb-1 block text-sm font-medium text-gray-700">Language</label>
324
- <select
325
- value={language}
326
- onChange={(e) => setLanguage(e.target.value)}
327
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
328
- >
329
- <option value="en">English</option>
330
- <option value="es">Spanish</option>
331
- <option value="fr">French</option>
332
- <option value="de">German</option>
333
- </select>
334
- </div>
335
- <div>
336
- <label className="mb-1 block text-sm font-medium text-gray-700">Timezone</label>
337
- <select
338
- value={timezone}
339
- onChange={(e) => setTimezone(e.target.value)}
340
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
341
- >
342
- <option value="UTC">UTC</option>
343
- <option value="America/New_York">Eastern Time</option>
344
- <option value="America/Chicago">Central Time</option>
345
- <option value="America/Denver">Mountain Time</option>
346
- <option value="America/Los_Angeles">Pacific Time</option>
347
- </select>
348
- </div>
349
- </div>
350
- </div>
351
- <div className="border-border bg-card rounded-lg border p-4">
352
- <h3 className="text-foreground mb-1 text-sm font-medium">SEO & Robots Defaults</h3>
353
- <p className="text-muted-foreground mb-4 text-xs">
354
- Set the site-wide default for search engine indexing. Individual pages can inherit or
355
- override these rules in their SEO panel.
356
- </p>
357
- <div className="space-y-4">
358
- <ToggleSetting
359
- label="Default No Index"
360
- description="Ask search engines not to index pages unless a page explicitly allows indexing"
361
- checked={defaultNoIndex}
362
- onChange={setDefaultNoIndex}
363
- />
364
- <ToggleSetting
365
- label="Default No Follow"
366
- description="Ask search engines not to follow page links unless a page explicitly allows following"
367
- checked={defaultNoFollow}
368
- onChange={setDefaultNoFollow}
369
- />
370
- <ToggleSetting
371
- label="Noindex Non-Production Environments"
372
- description="Force noindex when the deployed environment is not production"
373
- checked={noIndexNonProduction}
374
- onChange={setNoIndexNonProduction}
375
- />
376
- </div>
377
- </div>
273
+ <Tabs.Content value="general">
274
+ <GeneralSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
378
275
  </Tabs.Content>
379
276
 
380
277
  <Tabs.Content value="appearance" className="space-y-4">
381
- <div className="rounded-lg border border-gray-200 bg-white p-4">
382
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Theme</h3>
383
- <div className="space-y-4">
384
- <div>
385
- <label className="mb-1 block text-sm font-medium text-gray-700">Color Scheme</label>
386
- <ThemeSelect />
387
- </div>
388
- <div>
389
- <label className="mb-1 block text-sm font-medium text-gray-700">
390
- Primary Color
391
- </label>
392
- <input
393
- type="color"
394
- defaultValue="#3b82f6"
395
- className="h-10 w-full rounded-lg border border-gray-300 px-3 py-2"
396
- />
397
- </div>
398
- </div>
399
- </div>
278
+ <AppearanceTab role={session?.user?.role} config={config} />
400
279
  </Tabs.Content>
401
280
 
402
281
  {hasLayoutRegions && (
@@ -725,21 +604,6 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
725
604
  )
726
605
  }
727
606
 
728
- function ThemeSelect() {
729
- const { theme, setTheme } = useTheme()
730
- return (
731
- <select
732
- value={theme}
733
- onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}
734
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
735
- >
736
- <option value="light">Light</option>
737
- <option value="dark">Dark</option>
738
- <option value="system">Auto (System)</option>
739
- </select>
740
- )
741
- }
742
-
743
607
  function ToggleSetting({
744
608
  label,
745
609
  description,
@@ -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
+ }