@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.
- package/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts +2 -0
- package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useAppearanceBootstrap.test.js +55 -0
- package/dist/__tests__/hooks/useAppearanceBootstrap.test.js.map +1 -0
- package/dist/__tests__/lib/appearance-api.test.d.ts +2 -0
- package/dist/__tests__/lib/appearance-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/appearance-api.test.js +37 -0
- package/dist/__tests__/lib/appearance-api.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/EnabledSwitch.d.ts +13 -0
- package/dist/components/EnabledSwitch.d.ts.map +1 -0
- package/dist/components/EnabledSwitch.js +11 -0
- package/dist/components/EnabledSwitch.js.map +1 -0
- package/dist/hooks/useAppearanceBootstrap.d.ts +8 -0
- package/dist/hooks/useAppearanceBootstrap.d.ts.map +1 -0
- package/dist/hooks/useAppearanceBootstrap.js +36 -0
- package/dist/hooks/useAppearanceBootstrap.js.map +1 -0
- package/dist/lib/appearance-api.d.ts +33 -0
- package/dist/lib/appearance-api.d.ts.map +1 -0
- package/dist/lib/appearance-api.js +42 -0
- package/dist/lib/appearance-api.js.map +1 -0
- package/dist/views/ScriptTagEditor.d.ts.map +1 -1
- package/dist/views/ScriptTagEditor.js +2 -1
- package/dist/views/ScriptTagEditor.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +29 -8
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/settings/useAppearanceSettings.d.ts +3 -9
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +6 -28
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +2 -0
- package/src/__tests__/hooks/useAppearanceBootstrap.test.ts +69 -0
- package/src/__tests__/lib/appearance-api.test.ts +41 -0
- package/src/components/EnabledSwitch.tsx +34 -0
- package/src/hooks/useAppearanceBootstrap.ts +40 -0
- package/src/lib/appearance-api.ts +84 -0
- package/src/views/ScriptTagEditor.tsx +6 -12
- package/src/views/ScriptTags.tsx +40 -19
- 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
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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>
|
package/src/views/ScriptTags.tsx
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
78
|
+
|
|
79
|
+
const res = await cmsApi<{ enabled?: boolean }>(`/script-tags/${tag.id}`, {
|
|
62
80
|
method: 'PUT',
|
|
63
|
-
body: JSON.stringify({ 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
|
-
|
|
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
|
-
<
|
|
171
|
-
|
|
172
|
-
onClick={() => toggleEnabled(tag)}
|
|
198
|
+
<EnabledSwitch
|
|
199
|
+
checked={tag.enabled}
|
|
173
200
|
disabled={togglingId === tag.id}
|
|
174
|
-
|
|
175
|
-
aria-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
283
|
+
applyAppearance(appearanceApiToSnapshot(d))
|
|
328
284
|
setSaveState('idle')
|
|
329
285
|
setLoading(false)
|
|
330
|
-
}, [
|
|
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(
|
|
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') {
|