@actuate-media/cms-admin 0.52.0 → 0.53.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/CHANGELOG.md +22 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +48 -30
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-brand.render.test.js +61 -13
- package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
- package/dist/__tests__/lib/collection-routes.test.d.ts +2 -0
- package/dist/__tests__/lib/collection-routes.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collection-routes.test.js +29 -0
- package/dist/__tests__/lib/collection-routes.test.js.map +1 -0
- package/dist/__tests__/lib/favicon.test.d.ts +2 -0
- package/dist/__tests__/lib/favicon.test.d.ts.map +1 -0
- package/dist/__tests__/lib/favicon.test.js +43 -0
- package/dist/__tests__/lib/favicon.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-nav.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +54 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +29 -11
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/collection-routes.d.ts +18 -0
- package/dist/lib/collection-routes.d.ts.map +1 -0
- package/dist/lib/collection-routes.js +27 -0
- package/dist/lib/collection-routes.js.map +1 -0
- package/dist/lib/favicon.d.ts +28 -0
- package/dist/lib/favicon.d.ts.map +1 -0
- package/dist/lib/favicon.js +36 -0
- package/dist/lib/favicon.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +10 -3
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +1 -19
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/settings/BrandingCard.d.ts.map +1 -1
- package/dist/views/settings/BrandingCard.js +6 -2
- package/dist/views/settings/BrandingCard.js.map +1 -1
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +6 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +50 -30
- package/src/__tests__/layout/sidebar-brand.render.test.tsx +92 -16
- package/src/__tests__/lib/collection-routes.test.ts +37 -0
- package/src/__tests__/lib/favicon.test.ts +55 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +62 -0
- package/src/layout/Sidebar.tsx +39 -11
- package/src/lib/collection-routes.ts +35 -0
- package/src/lib/favicon.ts +44 -0
- package/src/views/CollectionList.tsx +20 -4
- package/src/views/Dashboard.tsx +1 -18
- package/src/views/settings/BrandingCard.tsx +7 -2
- package/src/views/settings/useAppearanceSettings.ts +6 -0
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -98,16 +98,39 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
98
98
|
const initials = adminBrand.initials
|
|
99
99
|
const hasInitials = Boolean(initials) && initials !== 'A'
|
|
100
100
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
101
|
+
// Track image URLs that fail to load (broken/expired/deleted asset) so the
|
|
102
|
+
// resolution falls through to the next option instead of showing the browser's
|
|
103
|
+
// broken-image icon in the sidebar header.
|
|
104
|
+
const [failedSrcs, setFailedSrcs] = useState<Set<string>>(new Set())
|
|
105
|
+
const markFailed = (src: string) =>
|
|
106
|
+
setFailedSrcs((prev) => (prev.has(src) ? prev : new Set(prev).add(src)))
|
|
107
|
+
const usable = (url: string | null | undefined): string | null =>
|
|
108
|
+
url && !failedSrcs.has(url) ? url : null
|
|
109
|
+
const onErr = (src: string) => () => markFailed(src)
|
|
110
|
+
|
|
111
|
+
// The admin brand is ALWAYS the logo — the favicon is only for the browser tab
|
|
112
|
+
// and is never used here (see resolveAdminBrand). Prefer the dark logo on dark
|
|
113
|
+
// backgrounds when supplied, falling back to the primary if the dark asset is
|
|
114
|
+
// missing or broken.
|
|
115
|
+
const darkPref = resolvedTheme === 'dark' ? adminBrand.darkUrl : null
|
|
116
|
+
const primary = usable(darkPref) ?? usable(adminBrand.primaryUrl)
|
|
117
|
+
const mark = usable(adminBrand.markUrl)
|
|
118
|
+
const cfg = usable(configLogo)
|
|
105
119
|
const alt = brandName ?? 'Admin'
|
|
106
120
|
|
|
121
|
+
// Wide horizontal logos must scale down to fit the narrow rail / sidebar
|
|
122
|
+
// header instead of overflowing — height-capped, width auto, never wider than
|
|
123
|
+
// the available space.
|
|
124
|
+
const logoClass = 'h-8 w-auto max-w-full object-contain'
|
|
125
|
+
|
|
107
126
|
if (collapsed) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
127
|
+
// Collapsed rail prefers a dedicated square mark; otherwise it shows the
|
|
128
|
+
// logo (scaled to fit) — never the favicon.
|
|
129
|
+
if (mark)
|
|
130
|
+
return <img src={mark} alt={alt} className="h-8 w-8 object-contain" onError={onErr(mark)} />
|
|
131
|
+
if (primary)
|
|
132
|
+
return <img src={primary} alt={alt} className={logoClass} onError={onErr(primary)} />
|
|
133
|
+
if (cfg) return <img src={cfg} alt={alt} className={logoClass} onError={onErr(cfg)} />
|
|
111
134
|
if (hasInitials) return <LogoPlaceholder tone="sidebar" />
|
|
112
135
|
return <ActuateMark className="h-8 w-8" />
|
|
113
136
|
}
|
|
@@ -115,7 +138,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
115
138
|
if (primary) {
|
|
116
139
|
return (
|
|
117
140
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
118
|
-
<img src={primary} alt={alt} className=
|
|
141
|
+
<img src={primary} alt={alt} className={logoClass} onError={onErr(primary)} />
|
|
119
142
|
{brandName && (
|
|
120
143
|
<span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
|
|
121
144
|
)}
|
|
@@ -123,10 +146,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
123
146
|
)
|
|
124
147
|
}
|
|
125
148
|
|
|
126
|
-
if (
|
|
149
|
+
if (cfg) {
|
|
127
150
|
return (
|
|
128
151
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
129
|
-
<img src={
|
|
152
|
+
<img src={cfg} alt={alt} className={logoClass} onError={onErr(cfg)} />
|
|
130
153
|
{brandName && (
|
|
131
154
|
<span className="text-sidebar-foreground truncate text-sm font-medium">{brandName}</span>
|
|
132
155
|
)}
|
|
@@ -137,7 +160,12 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
137
160
|
if (mark) {
|
|
138
161
|
return (
|
|
139
162
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
140
|
-
<img
|
|
163
|
+
<img
|
|
164
|
+
src={mark}
|
|
165
|
+
alt={alt}
|
|
166
|
+
className="h-8 w-8 shrink-0 object-contain"
|
|
167
|
+
onError={onErr(mark)}
|
|
168
|
+
/>
|
|
141
169
|
<span className="text-sidebar-foreground truncate text-sm font-medium">
|
|
142
170
|
{brandName ?? alt}
|
|
143
171
|
</span>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical admin routes for opening / creating documents.
|
|
3
|
+
*
|
|
4
|
+
* Post-type and Pages collections have dedicated section-based editors that the
|
|
5
|
+
* generic `/:collection/:id` route does NOT reach — routing a post through the
|
|
6
|
+
* generic editor silently strips its `sections` data, and a post collection
|
|
7
|
+
* whose slug is `posts` 404s under `/:collection/:id` entirely. Centralizing the
|
|
8
|
+
* mapping here keeps `Dashboard`, `CollectionList`, and any future caller in
|
|
9
|
+
* agreement instead of each re-deriving (and getting it wrong).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface CollectionRouteMeta {
|
|
13
|
+
type?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The route that opens an existing document in its correct editor. */
|
|
17
|
+
export function editPathForDoc(
|
|
18
|
+
col: CollectionRouteMeta | undefined,
|
|
19
|
+
collection: string,
|
|
20
|
+
id: string,
|
|
21
|
+
): string {
|
|
22
|
+
if (col?.type === 'post') return `/posts/${collection}/${id}/edit`
|
|
23
|
+
if (collection === 'pages') return `/pages/${id}/edit`
|
|
24
|
+
return `/${collection}/${id}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The route that starts a new document in the correct editor. */
|
|
28
|
+
export function newPathForCollection(
|
|
29
|
+
col: CollectionRouteMeta | undefined,
|
|
30
|
+
collection: string,
|
|
31
|
+
): string {
|
|
32
|
+
if (col?.type === 'post') return `/posts/${collection}/new`
|
|
33
|
+
if (collection === 'pages') return `/pages/new`
|
|
34
|
+
return `/${collection}/new`
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-tab favicon helpers for the admin SPA.
|
|
3
|
+
*
|
|
4
|
+
* The admin runs as a client app, so the favicon shown in the browser tab is
|
|
5
|
+
* whatever `<link rel="icon">` the document currently has. The Settings →
|
|
6
|
+
* Appearance favicon (stored in the CMS) is the source of truth, so the admin
|
|
7
|
+
* applies it at runtime instead of relying on the host app's `<head>` wiring.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Window event the admin dispatches after the brand (logo/favicon) is saved so
|
|
12
|
+
* live surfaces — the browser tab favicon — refresh without a full reload.
|
|
13
|
+
*/
|
|
14
|
+
export const BRAND_UPDATED_EVENT = 'actuate:brand-updated'
|
|
15
|
+
|
|
16
|
+
export interface FaviconUpdate {
|
|
17
|
+
/** The `<link rel="icon">` element that is now active. */
|
|
18
|
+
link: HTMLLinkElement
|
|
19
|
+
/** True when we created the link (so callers can remove it on cleanup). */
|
|
20
|
+
created: boolean
|
|
21
|
+
/** The href the link had before this update — for restore on cleanup. */
|
|
22
|
+
previousHref: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Point the browser tab favicon at `href`, creating the `<link rel="icon">` when
|
|
27
|
+
* the document doesn't already have one. Returns enough state for the caller to
|
|
28
|
+
* restore/remove it on cleanup. Client-only — call inside an effect.
|
|
29
|
+
*/
|
|
30
|
+
export function setDocumentFavicon(href: string, type?: string | null): FaviconUpdate {
|
|
31
|
+
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
32
|
+
if (existing) {
|
|
33
|
+
const previousHref = existing.href || null
|
|
34
|
+
existing.href = href
|
|
35
|
+
if (type) existing.type = type
|
|
36
|
+
return { link: existing, created: false, previousHref }
|
|
37
|
+
}
|
|
38
|
+
const link = document.createElement('link')
|
|
39
|
+
link.rel = 'icon'
|
|
40
|
+
link.href = href
|
|
41
|
+
if (type) link.type = type
|
|
42
|
+
document.head.appendChild(link)
|
|
43
|
+
return { link, created: true, previousHref: null }
|
|
44
|
+
}
|
|
@@ -14,6 +14,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
|
14
14
|
import { toast } from 'sonner'
|
|
15
15
|
import { cmsApi } from '../lib/api.js'
|
|
16
16
|
import { useApiData } from '../lib/useApiData.js'
|
|
17
|
+
import { editPathForDoc, newPathForCollection } from '../lib/collection-routes.js'
|
|
17
18
|
|
|
18
19
|
export interface CollectionListProps {
|
|
19
20
|
collectionSlug: string
|
|
@@ -141,6 +142,21 @@ function formatDate(d: string | undefined): string {
|
|
|
141
142
|
export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
|
|
142
143
|
const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug])
|
|
143
144
|
const facets = useMemo(() => facetFieldsFor(config, collectionSlug), [config, collectionSlug])
|
|
145
|
+
// Post-type and Pages collections open in dedicated editors — never the
|
|
146
|
+
// generic `/:collection/:id` route (which 404s for a `posts`-slugged post
|
|
147
|
+
// collection and strips section data for any post type).
|
|
148
|
+
const collectionDef = useMemo(
|
|
149
|
+
() => resolveCollection(config, collectionSlug),
|
|
150
|
+
[config, collectionSlug],
|
|
151
|
+
)
|
|
152
|
+
const editPath = useCallback(
|
|
153
|
+
(id: string | number) => editPathForDoc(collectionDef, collectionSlug, String(id)),
|
|
154
|
+
[collectionDef, collectionSlug],
|
|
155
|
+
)
|
|
156
|
+
const newPath = useCallback(
|
|
157
|
+
() => newPathForCollection(collectionDef, collectionSlug),
|
|
158
|
+
[collectionDef, collectionSlug],
|
|
159
|
+
)
|
|
144
160
|
|
|
145
161
|
const [page, setPage] = useState(1)
|
|
146
162
|
const [search, setSearch] = useState('')
|
|
@@ -294,7 +310,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
294
310
|
</p>
|
|
295
311
|
</div>
|
|
296
312
|
<button
|
|
297
|
-
onClick={() => onNavigate(
|
|
313
|
+
onClick={() => onNavigate(newPath())}
|
|
298
314
|
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
|
299
315
|
style={{ background: 'var(--actuate-primary, #2563eb)' }}
|
|
300
316
|
>
|
|
@@ -430,7 +446,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
430
446
|
<FileText className="mb-3 h-10 w-10 opacity-40" />
|
|
431
447
|
<p className="mb-3 text-sm">No {labels.plural.toLowerCase()} found</p>
|
|
432
448
|
<button
|
|
433
|
-
onClick={() => onNavigate(
|
|
449
|
+
onClick={() => onNavigate(newPath())}
|
|
434
450
|
className="rounded-lg px-4 py-2 text-sm text-white"
|
|
435
451
|
style={{ background: 'var(--actuate-primary, #2563eb)' }}
|
|
436
452
|
>
|
|
@@ -503,7 +519,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
503
519
|
<td className="px-3 py-2">
|
|
504
520
|
<button
|
|
505
521
|
type="button"
|
|
506
|
-
onClick={() => onNavigate(
|
|
522
|
+
onClick={() => onNavigate(editPath(doc.id))}
|
|
507
523
|
className="text-left font-medium hover:underline"
|
|
508
524
|
style={{ color: 'var(--actuate-text, #111827)' }}
|
|
509
525
|
>
|
|
@@ -537,7 +553,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
537
553
|
<td className="px-3 py-2">
|
|
538
554
|
<button
|
|
539
555
|
type="button"
|
|
540
|
-
onClick={() => onNavigate(
|
|
556
|
+
onClick={() => onNavigate(editPath(doc.id))}
|
|
541
557
|
className="rounded p-1.5 transition-opacity hover:opacity-75"
|
|
542
558
|
title="Edit"
|
|
543
559
|
>
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type LucideIcon,
|
|
41
41
|
} from 'lucide-react'
|
|
42
42
|
import { useApiData } from '../lib/useApiData.js'
|
|
43
|
+
import { editPathForDoc } from '../lib/collection-routes.js'
|
|
43
44
|
|
|
44
45
|
interface DashboardStats {
|
|
45
46
|
totalDocuments: number
|
|
@@ -119,24 +120,6 @@ function collectionLabel(col: CollectionMeta, plural = true): string {
|
|
|
119
120
|
return col.labels?.singular ?? fallback
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
/**
|
|
123
|
-
* Resolve the correct editor route for a recent/queued document.
|
|
124
|
-
*
|
|
125
|
-
* Post-type documents MUST open in the section-based post editor
|
|
126
|
-
* (`/posts/:type/:id/edit`). Routing them through the generic
|
|
127
|
-
* `/:collection/:id` handler lands them in the legacy `DocumentEdit`
|
|
128
|
-
* view, which has no concept of the section model — opening + saving
|
|
129
|
-
* there silently strips a post's `sections` data. The canonical Pages
|
|
130
|
-
* collection opens in the full page editor (which wires up preview).
|
|
131
|
-
* Everything else falls through to the existing generic collection
|
|
132
|
-
* route, which already resolves correctly.
|
|
133
|
-
*/
|
|
134
|
-
function editPathForDoc(col: CollectionMeta | undefined, collection: string, id: string): string {
|
|
135
|
-
if (col?.type === 'post') return `/posts/${collection}/${id}/edit`
|
|
136
|
-
if (collection === 'pages') return `/pages/${id}/edit`
|
|
137
|
-
return `/${collection}/${id}`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
123
|
function relativeTime(dateStr: string): string {
|
|
141
124
|
const then = new Date(dateStr).getTime()
|
|
142
125
|
const diff = Date.now() - then
|
|
@@ -64,10 +64,14 @@ function LogoField({
|
|
|
64
64
|
}) {
|
|
65
65
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
66
66
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
67
|
+
// Remember a preview URL that failed to load so a broken/expired asset shows
|
|
68
|
+
// the neutral placeholder icon instead of the browser's broken-image glyph.
|
|
69
|
+
const [thumbErrorUrl, setThumbErrorUrl] = useState<string | null>(null)
|
|
67
70
|
const assetId = brand[field]
|
|
68
71
|
const asset = assetId ? assets[assetId] : undefined
|
|
69
72
|
const uploading = uploadingField === field
|
|
70
73
|
const inputId = `brand-${field}`
|
|
74
|
+
const showPreview = Boolean(asset?.url) && asset?.url !== thumbErrorUrl
|
|
71
75
|
|
|
72
76
|
const thumbnail = (
|
|
73
77
|
<div
|
|
@@ -76,12 +80,13 @@ function LogoField({
|
|
|
76
80
|
)} ${square ? 'h-12 w-12' : 'h-12 w-20'}`}
|
|
77
81
|
aria-hidden={!asset}
|
|
78
82
|
>
|
|
79
|
-
{
|
|
83
|
+
{showPreview ? (
|
|
80
84
|
// Never stretch/crop — contain preserves aspect ratio + transparency.
|
|
81
85
|
<img
|
|
82
|
-
src={asset
|
|
86
|
+
src={asset!.url}
|
|
83
87
|
alt={`${label} preview`}
|
|
84
88
|
className="max-h-full max-w-full object-contain"
|
|
89
|
+
onError={() => setThumbErrorUrl(asset!.url)}
|
|
85
90
|
/>
|
|
86
91
|
) : (
|
|
87
92
|
<ImageIcon size={18} className="text-muted-foreground" aria-hidden="true" />
|
|
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
20
20
|
|
|
21
21
|
import { useTheme, type AdminBrandCache } from '../../components/ThemeProvider.js'
|
|
22
22
|
import { cmsApi } from '../../lib/api.js'
|
|
23
|
+
import { BRAND_UPDATED_EVENT } from '../../lib/favicon.js'
|
|
23
24
|
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
24
25
|
|
|
25
26
|
export type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
@@ -548,6 +549,11 @@ export function useAppearanceSettings(
|
|
|
548
549
|
}
|
|
549
550
|
baselineRef.current = snapshot(appearance, brand)
|
|
550
551
|
setAdminBrand(assetToCache(brand, assets, siteTitleRef.current))
|
|
552
|
+
// Notify live surfaces (browser-tab favicon) to refresh from the saved
|
|
553
|
+
// brand without a full reload.
|
|
554
|
+
if (typeof window !== 'undefined') {
|
|
555
|
+
window.dispatchEvent(new CustomEvent(BRAND_UPDATED_EVENT))
|
|
556
|
+
}
|
|
551
557
|
setSaveState('success')
|
|
552
558
|
}, [canEdit, issues, appearance, brand, assets, setAdminBrand])
|
|
553
559
|
|