@actuate-media/cms-admin 0.63.0 → 0.63.2

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +2 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useAppearanceBootstrap.test.js +55 -0
  8. package/dist/__tests__/hooks/useAppearanceBootstrap.test.js.map +1 -0
  9. package/dist/__tests__/lib/appearance-api.test.d.ts +2 -0
  10. package/dist/__tests__/lib/appearance-api.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/appearance-api.test.js +37 -0
  12. package/dist/__tests__/lib/appearance-api.test.js.map +1 -0
  13. package/dist/actuate-admin.css +1 -1
  14. package/dist/components/EnabledSwitch.d.ts +13 -0
  15. package/dist/components/EnabledSwitch.d.ts.map +1 -0
  16. package/dist/components/EnabledSwitch.js +11 -0
  17. package/dist/components/EnabledSwitch.js.map +1 -0
  18. package/dist/hooks/useAppearanceBootstrap.d.ts +8 -0
  19. package/dist/hooks/useAppearanceBootstrap.d.ts.map +1 -0
  20. package/dist/hooks/useAppearanceBootstrap.js +36 -0
  21. package/dist/hooks/useAppearanceBootstrap.js.map +1 -0
  22. package/dist/lib/appearance-api.d.ts +33 -0
  23. package/dist/lib/appearance-api.d.ts.map +1 -0
  24. package/dist/lib/appearance-api.js +42 -0
  25. package/dist/lib/appearance-api.js.map +1 -0
  26. package/dist/views/ScriptTagEditor.d.ts.map +1 -1
  27. package/dist/views/ScriptTagEditor.js +2 -1
  28. package/dist/views/ScriptTagEditor.js.map +1 -1
  29. package/dist/views/ScriptTags.d.ts.map +1 -1
  30. package/dist/views/ScriptTags.js +29 -8
  31. package/dist/views/ScriptTags.js.map +1 -1
  32. package/dist/views/settings/useAppearanceSettings.d.ts +3 -9
  33. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  34. package/dist/views/settings/useAppearanceSettings.js +6 -28
  35. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/AdminRoot.tsx +2 -0
  38. package/src/__tests__/hooks/useAppearanceBootstrap.test.ts +69 -0
  39. package/src/__tests__/lib/appearance-api.test.ts +41 -0
  40. package/src/components/EnabledSwitch.tsx +34 -0
  41. package/src/hooks/useAppearanceBootstrap.ts +40 -0
  42. package/src/lib/appearance-api.ts +84 -0
  43. package/src/views/ScriptTagEditor.tsx +6 -12
  44. package/src/views/ScriptTags.tsx +40 -19
  45. package/src/views/settings/useAppearanceSettings.ts +14 -58
@@ -0,0 +1,84 @@
1
+ import {
2
+ classifyLogoShape,
3
+ resolveAdminBrand,
4
+ type BrandAssetLike,
5
+ type BrandSettings,
6
+ type ColorMode,
7
+ type LogoShape,
8
+ type SidebarDensity,
9
+ } from '@actuate-media/cms-core/appearance'
10
+
11
+ import type { AdminBrandCache, AppearanceSnapshot } from '../components/ThemeProvider.js'
12
+
13
+ /** GET /appearance response shape (admin Appearance tab + boot hydration). */
14
+ export interface AppearanceApiResponse {
15
+ appearance: {
16
+ adminColorModeDefault: ColorMode
17
+ adminAccentColor: string
18
+ sidebarDensity: SidebarDensity
19
+ contentFont: string
20
+ reduceMotion: boolean
21
+ }
22
+ brand: BrandSettings
23
+ resolved: { admin: { initials: string } }
24
+ assets: BrandAssetLike[]
25
+ siteTitle: string | null
26
+ }
27
+
28
+ export interface AssetPreview {
29
+ id: string
30
+ url: string
31
+ mimeType: string
32
+ width?: number
33
+ height?: number
34
+ shape: LogoShape
35
+ }
36
+
37
+ export function parseAppearanceAssets(assets: BrandAssetLike[]): Record<string, AssetPreview> {
38
+ const next: Record<string, AssetPreview> = {}
39
+ for (const a of assets ?? []) {
40
+ next[a.id] = {
41
+ id: a.id,
42
+ url: a.publicUrl,
43
+ mimeType: a.mimeType,
44
+ width: a.width ?? undefined,
45
+ height: a.height ?? undefined,
46
+ shape: classifyLogoShape(a.width ?? undefined, a.height ?? undefined),
47
+ }
48
+ }
49
+ return next
50
+ }
51
+
52
+ export function adminBrandToCache(
53
+ brand: BrandSettings,
54
+ assets: Record<string, AssetPreview>,
55
+ siteTitle: string | null,
56
+ ): AdminBrandCache {
57
+ const map = new Map<string, BrandAssetLike>(
58
+ Object.values(assets).map((a) => [
59
+ a.id,
60
+ { id: a.id, publicUrl: a.url, mimeType: a.mimeType, width: a.width, height: a.height },
61
+ ]),
62
+ )
63
+ const resolved = resolveAdminBrand(brand, map, siteTitle)
64
+ return {
65
+ primaryUrl: resolved.primary?.url ?? null,
66
+ markUrl: resolved.mark?.url ?? null,
67
+ darkUrl: resolved.dark?.url ?? null,
68
+ initials: resolved.initials,
69
+ }
70
+ }
71
+
72
+ /** Map a stored appearance payload into the ThemeProvider snapshot. */
73
+ export function appearanceApiToSnapshot(data: AppearanceApiResponse): AppearanceSnapshot {
74
+ const brand: BrandSettings = { ...data.brand, mode: data.brand?.mode ?? 'inheritPublicBrand' }
75
+ const assets = parseAppearanceAssets(data.assets)
76
+ return {
77
+ theme: data.appearance.adminColorModeDefault,
78
+ accent: data.appearance.adminAccentColor,
79
+ density: data.appearance.sidebarDensity,
80
+ font: data.appearance.contentFont,
81
+ reduceMotion: data.appearance.reduceMotion,
82
+ adminBrand: adminBrandToCache(brand, assets, data.siteTitle ?? null),
83
+ }
84
+ }
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect } from 'react'
4
4
  import { ArrowLeft, Loader2, AlertTriangle, Trash2, X, Plus } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
+ import { EnabledSwitch } from '../components/EnabledSwitch.js'
6
7
  import { useApiData } from '../lib/useApiData.js'
7
8
  import { cmsApi } from '../lib/api.js'
8
9
 
@@ -284,18 +285,11 @@ export function ScriptTagEditor({ tagId, onNavigate }: ScriptTagEditorProps) {
284
285
  <div>
285
286
  <label className="text-foreground mb-1 block text-sm font-medium">Enabled</label>
286
287
  <div className="pt-2">
287
- <button
288
- type="button"
289
- onClick={() => setEnabled(!enabled)}
290
- className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-switch-background'}`}
291
- aria-pressed={enabled}
292
- >
293
- <span
294
- className={`bg-card absolute top-0.5 block h-5 w-5 rounded-full transition-transform ${
295
- enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
296
- }`}
297
- />
298
- </button>
288
+ <EnabledSwitch
289
+ checked={enabled}
290
+ onCheckedChange={setEnabled}
291
+ aria-label={enabled ? 'Disable script tag' : 'Enable script tag'}
292
+ />
299
293
  </div>
300
294
  </div>
301
295
  </div>
@@ -1,8 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useMemo, useState } from 'react'
4
4
  import { Code2, Plus, Loader2, AlertTriangle } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
+ import { EnabledSwitch } from '../components/EnabledSwitch.js'
6
7
  import { useApiData } from '../lib/useApiData.js'
7
8
  import { cmsApi } from '../lib/api.js'
8
9
 
@@ -35,6 +36,13 @@ const PLACEMENT_COLORS: Record<string, string> = {
35
36
  body_close: 'bg-success/15 text-success',
36
37
  }
37
38
 
39
+ function normalizeEnabled(value: unknown): boolean {
40
+ if (typeof value === 'boolean') return value
41
+ if (value === 'true' || value === 1) return true
42
+ if (value === 'false' || value === 0 || value === null) return false
43
+ return true
44
+ }
45
+
38
46
  function scopeLabel(tag: ScriptTag): string {
39
47
  const scope = String(tag.scope ?? '')
40
48
  const targetPaths = Array.isArray(tag.targetPaths) ? tag.targetPaths : []
@@ -53,21 +61,41 @@ function scopeLabel(tag: ScriptTag): string {
53
61
  export function ScriptTags({ onNavigate }: ScriptTagsProps) {
54
62
  const { data, loading, error, refetch } = useApiData<ScriptTag[]>('/script-tags')
55
63
  const [togglingId, setTogglingId] = useState<string | null>(null)
64
+ const [enabledOverrides, setEnabledOverrides] = useState<Record<string, boolean>>({})
56
65
 
57
- const tags = data ?? []
66
+ const tags = useMemo(
67
+ () =>
68
+ (data ?? []).map((tag) => ({
69
+ ...tag,
70
+ enabled: enabledOverrides[tag.id] ?? normalizeEnabled(tag.enabled),
71
+ })),
72
+ [data, enabledOverrides],
73
+ )
58
74
 
59
- const toggleEnabled = async (tag: ScriptTag) => {
75
+ const toggleEnabled = async (tag: ScriptTag, nextEnabled: boolean) => {
76
+ setEnabledOverrides((prev) => ({ ...prev, [tag.id]: nextEnabled }))
60
77
  setTogglingId(tag.id)
61
- const res = await cmsApi(`/script-tags/${tag.id}`, {
78
+
79
+ const res = await cmsApi<{ enabled?: boolean }>(`/script-tags/${tag.id}`, {
62
80
  method: 'PUT',
63
- body: JSON.stringify({ enabled: !tag.enabled }),
81
+ body: JSON.stringify({ enabled: nextEnabled }),
64
82
  })
83
+
65
84
  setTogglingId(null)
85
+
66
86
  if (res.error) {
87
+ setEnabledOverrides((prev) => {
88
+ const { [tag.id]: _, ...rest } = prev
89
+ return rest
90
+ })
67
91
  toast.error(res.error)
68
- } else {
69
- refetch()
92
+ return
70
93
  }
94
+
95
+ const saved = normalizeEnabled(res.data?.enabled ?? nextEnabled)
96
+ setEnabledOverrides((prev) => ({ ...prev, [tag.id]: saved }))
97
+ refetch()
98
+ toast.success(saved ? 'Tag enabled' : 'Tag disabled')
71
99
  }
72
100
 
73
101
  if (loading) {
@@ -167,19 +195,12 @@ export function ScriptTags({ onNavigate }: ScriptTagsProps) {
167
195
  {tag.priority}
168
196
  </td>
169
197
  <td className="px-4 py-3 text-center">
170
- <button
171
- type="button"
172
- onClick={() => toggleEnabled(tag)}
198
+ <EnabledSwitch
199
+ checked={tag.enabled}
173
200
  disabled={togglingId === tag.id}
174
- className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${tag.enabled ? 'bg-primary' : 'bg-switch-background'}`}
175
- aria-pressed={tag.enabled}
176
- >
177
- <span
178
- className={`bg-card absolute top-0.5 block h-5 w-5 rounded-full transition-transform ${
179
- tag.enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
180
- }`}
181
- />
182
- </button>
201
+ onCheckedChange={(next) => void toggleEnabled(tag, next)}
202
+ aria-label={`${tag.enabled ? 'Disable' : 'Enable'} ${tag.name}`}
203
+ />
183
204
  </td>
184
205
  </tr>
185
206
  ))}
@@ -18,7 +18,14 @@ import {
18
18
  } from '@actuate-media/cms-core/appearance'
19
19
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
20
20
 
21
- import { useTheme, type AdminBrandCache } from '../../components/ThemeProvider.js'
21
+ import { useTheme } from '../../components/ThemeProvider.js'
22
+ import {
23
+ adminBrandToCache,
24
+ appearanceApiToSnapshot,
25
+ parseAppearanceAssets,
26
+ type AppearanceApiResponse,
27
+ type AssetPreview,
28
+ } from '../../lib/appearance-api.js'
22
29
  import { cmsApi } from '../../lib/api.js'
23
30
  import { BRAND_UPDATED_EVENT } from '../../lib/favicon.js'
24
31
  import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
@@ -67,14 +74,7 @@ export const FIELD_ROLE: Record<BrandAssetField, BrandAssetRole> = {
67
74
  faviconSourceAssetId: 'faviconSource',
68
75
  }
69
76
 
70
- export interface AssetPreview {
71
- id: string
72
- url: string
73
- mimeType: string
74
- width?: number
75
- height?: number
76
- shape: LogoShape
77
- }
77
+ export type { AssetPreview } from '../../lib/appearance-api.js'
78
78
 
79
79
  /** An existing media-library asset chosen for a brand field via the picker. */
80
80
  export interface SelectedBrandAsset {
@@ -163,20 +163,6 @@ function snapshot(a: AppearanceForm, b: BrandForm): string {
163
163
  return JSON.stringify({ a, b })
164
164
  }
165
165
 
166
- interface AppearanceApiResponse {
167
- appearance: {
168
- adminColorModeDefault: ColorMode
169
- adminAccentColor: string
170
- sidebarDensity: SidebarDensity
171
- contentFont: string
172
- reduceMotion: boolean
173
- }
174
- brand: BrandSettings
175
- resolved: { admin: { initials: string } }
176
- assets: BrandAssetLike[]
177
- siteTitle: string | null
178
- }
179
-
180
166
  /** Read an uploaded image's shape client-side (raster via Image, SVG via XML). */
181
167
  async function detectShape(file: File): Promise<LogoShape> {
182
168
  if (file.type === 'image/svg+xml') {
@@ -213,26 +199,6 @@ async function detectShape(file: File): Promise<LogoShape> {
213
199
  }
214
200
  }
215
201
 
216
- function assetToCache(
217
- brand: BrandForm,
218
- assets: Record<string, AssetPreview>,
219
- siteTitle: string | null,
220
- ): AdminBrandCache {
221
- const map = new Map<string, BrandAssetLike>(
222
- Object.values(assets).map((a) => [
223
- a.id,
224
- { id: a.id, publicUrl: a.url, mimeType: a.mimeType, width: a.width, height: a.height },
225
- ]),
226
- )
227
- const resolved = resolveAdminBrand(brand as BrandSettings, map, siteTitle)
228
- return {
229
- primaryUrl: resolved.primary?.url ?? null,
230
- markUrl: resolved.mark?.url ?? null,
231
- darkUrl: resolved.dark?.url ?? null,
232
- initials: resolved.initials,
233
- }
234
- }
235
-
236
202
  export function useAppearanceSettings(
237
203
  canEdit: boolean,
238
204
  config?: Record<string, unknown>,
@@ -283,7 +249,7 @@ export function useAppearanceSettings(
283
249
  density: a.density,
284
250
  font: a.font,
285
251
  reduceMotion: a.reduceMotion,
286
- adminBrand: assetToCache(b, assetMap, siteTitleRef.current),
252
+ adminBrand: adminBrandToCache(b as BrandSettings, assetMap, siteTitleRef.current),
287
253
  })
288
254
  },
289
255
  [applyAppearance],
@@ -307,27 +273,17 @@ export function useAppearanceSettings(
307
273
  reduceMotion: d.appearance.reduceMotion,
308
274
  }
309
275
  const nextBrand: BrandForm = { ...d.brand, mode: d.brand?.mode ?? 'inheritPublicBrand' }
310
- const nextAssets: Record<string, AssetPreview> = {}
311
- for (const a of d.assets ?? []) {
312
- nextAssets[a.id] = {
313
- id: a.id,
314
- url: a.publicUrl,
315
- mimeType: a.mimeType,
316
- width: a.width ?? undefined,
317
- height: a.height ?? undefined,
318
- shape: classifyLogoShape(a.width ?? undefined, a.height ?? undefined),
319
- }
320
- }
276
+ const nextAssets = parseAppearanceAssets(d.assets ?? [])
321
277
  siteTitleRef.current = d.siteTitle ?? null
322
278
  setAppearance(nextAppearance)
323
279
  setBrand(nextBrand)
324
280
  setAssets(nextAssets)
325
281
  if (nextBrand.adminLogoMarkAssetId || nextBrand.publicLogoMarkAssetId) setShowAdvanced(true)
326
282
  baselineRef.current = snapshot(nextAppearance, nextBrand)
327
- pushLive(nextAppearance, nextBrand, nextAssets)
283
+ applyAppearance(appearanceApiToSnapshot(d))
328
284
  setSaveState('idle')
329
285
  setLoading(false)
330
- }, [pushLive])
286
+ }, [applyAppearance])
331
287
 
332
288
  useEffect(() => {
333
289
  void load()
@@ -548,7 +504,7 @@ export function useAppearanceSettings(
548
504
  return
549
505
  }
550
506
  baselineRef.current = snapshot(appearance, brand)
551
- setAdminBrand(assetToCache(brand, assets, siteTitleRef.current))
507
+ setAdminBrand(adminBrandToCache(brand as BrandSettings, assets, siteTitleRef.current))
552
508
  // Notify live surfaces (browser-tab favicon) to refresh from the saved
553
509
  // brand without a full reload.
554
510
  if (typeof window !== 'undefined') {