@actuate-media/cms-admin 0.52.1 → 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 +16 -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/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/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/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/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/favicon.test.ts +55 -0
- package/src/layout/Sidebar.tsx +39 -11
- package/src/lib/favicon.ts +44 -0
- 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,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
|
+
}
|
|
@@ -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
|
|