@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
package/src/views/Settings.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
'
|
|
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"
|
|
287
|
-
<
|
|
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
|
-
<
|
|
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
|
+
}
|