@actuate-media/cms-admin 0.52.1 → 0.54.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 +28 -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/__tests__/views/section-inspector-media.test.d.ts +2 -0
- package/dist/__tests__/views/section-inspector-media.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-inspector-media.test.js +21 -0
- package/dist/__tests__/views/section-inspector-media.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/page-editor/SectionInspector.d.ts +11 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +36 -7
- package/dist/views/page-editor/SectionInspector.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/favicon.test.ts +55 -0
- package/src/__tests__/views/section-inspector-media.test.ts +23 -0
- package/src/layout/Sidebar.tsx +39 -11
- package/src/lib/favicon.ts +44 -0
- package/src/views/page-editor/SectionInspector.tsx +43 -2
- package/src/views/settings/BrandingCard.tsx +7 -2
- package/src/views/settings/useAppearanceSettings.ts +6 -0
package/src/AdminRoot.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { Layout } from './layout/Layout.js'
|
|
|
7
7
|
import { adminPortalContainer } from './lib/portal-container.js'
|
|
8
8
|
import { useAdminRouter } from './router/index.js'
|
|
9
9
|
import { cmsApi, ensureCsrfToken } from './lib/api.js'
|
|
10
|
+
import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
|
|
10
11
|
import {
|
|
11
12
|
registerSectionTypes,
|
|
12
13
|
resetCustomSectionTypes,
|
|
@@ -743,50 +744,69 @@ function useBranding(config: any) {
|
|
|
743
744
|
|
|
744
745
|
useEffect(() => {
|
|
745
746
|
const branding = config?.admin?.branding
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
const link = document.createElement('link')
|
|
757
|
-
link.rel = 'icon'
|
|
758
|
-
link.href = branding.favicon
|
|
759
|
-
document.head.appendChild(link)
|
|
760
|
-
injectedLinkRef.current = link
|
|
761
|
-
}
|
|
747
|
+
|
|
748
|
+
// Capture the originals up-front so cleanup can restore them even after the
|
|
749
|
+
// DB favicon (below) overrides whatever config set.
|
|
750
|
+
originalFaviconRef.current =
|
|
751
|
+
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.href ?? null
|
|
752
|
+
originalTitleRef.current = document.title
|
|
753
|
+
|
|
754
|
+
const applyFavicon = (href: string, type?: string | null) => {
|
|
755
|
+
const { link, created } = setDocumentFavicon(href, type)
|
|
756
|
+
if (created && !injectedLinkRef.current) injectedLinkRef.current = link
|
|
762
757
|
}
|
|
763
758
|
|
|
759
|
+
// --- Static config favicon (instant, no network) ---
|
|
760
|
+
// Acts as a fast default; the DB favicon below overrides it when one is set
|
|
761
|
+
// in Settings → Appearance.
|
|
762
|
+
if (branding?.favicon) applyFavicon(branding.favicon)
|
|
763
|
+
|
|
764
764
|
// --- Document title ---
|
|
765
|
-
|
|
766
|
-
if (branding.title) {
|
|
765
|
+
if (branding?.title) {
|
|
767
766
|
document.title = branding.title
|
|
768
|
-
} else if (branding
|
|
767
|
+
} else if (branding?.name) {
|
|
769
768
|
document.title = `${branding.name} Admin`
|
|
770
769
|
}
|
|
771
770
|
|
|
772
771
|
// --- Primary color ---
|
|
773
772
|
const adminEl = document.querySelector<HTMLElement>('.actuate-admin')
|
|
774
|
-
if (branding
|
|
773
|
+
if (branding?.primaryColor && adminEl) {
|
|
775
774
|
adminEl.style.setProperty('--primary', branding.primaryColor)
|
|
776
775
|
adminEl.style.setProperty('--sidebar-primary', branding.primaryColor)
|
|
777
776
|
}
|
|
778
777
|
|
|
778
|
+
// --- DB favicon (Settings → Appearance) ---
|
|
779
|
+
// The uploaded favicon is the source of truth for the browser tab. Fetch it
|
|
780
|
+
// on mount and re-apply whenever the brand is saved, so the tab icon tracks
|
|
781
|
+
// the upload without a full reload. The admin request carries the session
|
|
782
|
+
// cookie, so the public/brand read bypasses any shared cache (see
|
|
783
|
+
// cache/http.ts) and is always fresh.
|
|
784
|
+
let cancelled = false
|
|
785
|
+
const applyDbFavicon = async () => {
|
|
786
|
+
try {
|
|
787
|
+
const res = await cmsApi<{ favicon?: { icon?: string | null; type?: string | null } }>(
|
|
788
|
+
'/public/brand',
|
|
789
|
+
)
|
|
790
|
+
const favicon = res.data?.favicon
|
|
791
|
+
if (!cancelled && favicon?.icon) applyFavicon(favicon.icon, favicon.type ?? undefined)
|
|
792
|
+
} catch {
|
|
793
|
+
/* brand API unavailable — keep the config/default favicon */
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
void applyDbFavicon()
|
|
797
|
+
const onBrandUpdated = () => void applyDbFavicon()
|
|
798
|
+
window.addEventListener(BRAND_UPDATED_EVENT, onBrandUpdated)
|
|
799
|
+
|
|
779
800
|
return () => {
|
|
801
|
+
cancelled = true
|
|
802
|
+
window.removeEventListener(BRAND_UPDATED_EVENT, onBrandUpdated)
|
|
803
|
+
|
|
780
804
|
// Restore favicon
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (injectedLinkRef.current) {
|
|
787
|
-
injectedLinkRef.current.remove()
|
|
788
|
-
injectedLinkRef.current = null
|
|
789
|
-
}
|
|
805
|
+
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
806
|
+
if (link && originalFaviconRef.current) link.href = originalFaviconRef.current
|
|
807
|
+
if (injectedLinkRef.current) {
|
|
808
|
+
injectedLinkRef.current.remove()
|
|
809
|
+
injectedLinkRef.current = null
|
|
790
810
|
}
|
|
791
811
|
|
|
792
812
|
// Restore title
|
|
@@ -795,7 +815,7 @@ function useBranding(config: any) {
|
|
|
795
815
|
}
|
|
796
816
|
|
|
797
817
|
// Remove primary color override
|
|
798
|
-
if (branding
|
|
818
|
+
if (branding?.primaryColor && adminEl) {
|
|
799
819
|
adminEl.style.removeProperty('--primary')
|
|
800
820
|
adminEl.style.removeProperty('--sidebar-primary')
|
|
801
821
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
// The sidebar fetches forms unread counts on mount; stub it so these brand
|
|
6
6
|
// tests stay offline and deterministic.
|
|
@@ -8,25 +8,40 @@ vi.mock('../../lib/forms-service.js', () => ({
|
|
|
8
8
|
fetchFormsSidebarCounts: vi.fn(async () => []),
|
|
9
9
|
}))
|
|
10
10
|
|
|
11
|
-
// Drive the brand lockup directly
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// Drive the brand lockup directly via a mutable theme state. happy-dom's
|
|
12
|
+
// localStorage stub is unreliable here, so we mock the theme hook rather than
|
|
13
|
+
// hydrating it. Tests mutate `themeState` before rendering.
|
|
14
|
+
interface ThemeState {
|
|
15
|
+
adminBrand: {
|
|
16
|
+
primaryUrl: string | null
|
|
17
|
+
markUrl: string | null
|
|
18
|
+
darkUrl: string | null
|
|
19
|
+
initials: string
|
|
20
|
+
}
|
|
21
|
+
resolvedTheme: string
|
|
22
|
+
density: string
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_THEME: ThemeState = {
|
|
25
|
+
adminBrand: { primaryUrl: null, markUrl: null, darkUrl: null, initials: 'AC' },
|
|
26
|
+
resolvedTheme: 'light',
|
|
27
|
+
density: 'default',
|
|
28
|
+
}
|
|
29
|
+
let themeState: ThemeState = { ...DEFAULT_THEME }
|
|
30
|
+
|
|
14
31
|
vi.mock('../../components/ThemeProvider.js', () => ({
|
|
15
|
-
useTheme: () =>
|
|
16
|
-
adminBrand: { primaryUrl: null, markUrl: null, darkUrl: null, initials: 'AC' },
|
|
17
|
-
resolvedTheme: 'light',
|
|
18
|
-
density: 'default',
|
|
19
|
-
}),
|
|
32
|
+
useTheme: () => themeState,
|
|
20
33
|
}))
|
|
21
34
|
|
|
22
35
|
const { Sidebar } = await import('../../layout/Sidebar.js')
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
function setTheme(brand: Partial<ThemeState['adminBrand']>, resolvedTheme = 'light') {
|
|
38
|
+
themeState = {
|
|
39
|
+
...DEFAULT_THEME,
|
|
40
|
+
resolvedTheme,
|
|
41
|
+
adminBrand: { ...DEFAULT_THEME.adminBrand, ...brand },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
function renderSidebar(collapsed: boolean, config?: unknown) {
|
|
31
46
|
return render(
|
|
32
47
|
<Sidebar
|
|
@@ -39,6 +54,16 @@ function renderSidebar(collapsed: boolean, config?: unknown) {
|
|
|
39
54
|
)
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
themeState = { ...DEFAULT_THEME }
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Brand lockup fallback behaviour. When an install is branded (a site name is
|
|
63
|
+
* set, so initials exist) but no logo has been uploaded, the sidebar must show
|
|
64
|
+
* the neutral logo placeholder — NOT a filled initials badge, which reads as a
|
|
65
|
+
* user avatar (the real account avatar already lives top-right).
|
|
66
|
+
*/
|
|
42
67
|
describe('Sidebar brand placeholder', () => {
|
|
43
68
|
it('renders the logo placeholder (not an initials avatar) when branded with no logo — expanded', () => {
|
|
44
69
|
renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
|
|
@@ -56,3 +81,54 @@ describe('Sidebar brand placeholder', () => {
|
|
|
56
81
|
expect(screen.queryByText('AC')).toBeNull()
|
|
57
82
|
})
|
|
58
83
|
})
|
|
84
|
+
|
|
85
|
+
describe('Sidebar brand logo rendering', () => {
|
|
86
|
+
it('shows the primary logo (scaled to fit) when collapsed — never the favicon', () => {
|
|
87
|
+
setTheme({ primaryUrl: 'https://cdn.example.com/logo.svg' })
|
|
88
|
+
renderSidebar(true)
|
|
89
|
+
|
|
90
|
+
const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
|
|
91
|
+
expect(img.src).toContain('logo.svg')
|
|
92
|
+
// Wide logos must be width-capped so they scale down instead of overflowing.
|
|
93
|
+
expect(img.className).toContain('max-w-full')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('caps the expanded logo width so wide logos stay responsive', () => {
|
|
97
|
+
setTheme({ primaryUrl: 'https://cdn.example.com/logo.svg' })
|
|
98
|
+
renderSidebar(false)
|
|
99
|
+
|
|
100
|
+
const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
|
|
101
|
+
expect(img.className).toContain('max-w-full')
|
|
102
|
+
expect(img.className).toContain('object-contain')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('prefers the dark logo in dark mode and falls back to primary on error', () => {
|
|
106
|
+
setTheme(
|
|
107
|
+
{
|
|
108
|
+
primaryUrl: 'https://cdn.example.com/logo.svg',
|
|
109
|
+
darkUrl: 'https://cdn.example.com/dark.svg',
|
|
110
|
+
},
|
|
111
|
+
'dark',
|
|
112
|
+
)
|
|
113
|
+
renderSidebar(false)
|
|
114
|
+
|
|
115
|
+
const img = screen.getByRole('img', { name: 'Admin' }) as HTMLImageElement
|
|
116
|
+
expect(img.src).toContain('dark.svg')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls through to the placeholder when the logo URL is broken (onError)', () => {
|
|
120
|
+
setTheme({ primaryUrl: 'https://cdn.example.com/broken.svg' })
|
|
121
|
+
renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
|
|
122
|
+
|
|
123
|
+
// With a brand name configured, the logo alt is the brand name.
|
|
124
|
+
const img = screen.getByRole('img', { name: 'Acme Co' }) as HTMLImageElement
|
|
125
|
+
expect(img.src).toContain('broken.svg')
|
|
126
|
+
// Simulate the asset URL failing to load.
|
|
127
|
+
fireEvent.error(img)
|
|
128
|
+
|
|
129
|
+
// The broken logo is gone; the neutral placeholder + brand name take over
|
|
130
|
+
// (no browser broken-image glyph left behind).
|
|
131
|
+
expect(screen.queryByRole('img', { name: 'Acme Co' })).toBeNull()
|
|
132
|
+
expect(screen.getByText('Acme Co')).toBeTruthy()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { setDocumentFavicon, BRAND_UPDATED_EVENT } from '../../lib/favicon.js'
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.head.querySelectorAll('link[rel="icon"]').forEach((l) => l.remove())
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('setDocumentFavicon', () => {
|
|
11
|
+
it('creates a link[rel=icon] when the document has none', () => {
|
|
12
|
+
const { created, previousHref, link } = setDocumentFavicon(
|
|
13
|
+
'https://cdn.example.com/fav.png',
|
|
14
|
+
'image/png',
|
|
15
|
+
)
|
|
16
|
+
expect(created).toBe(true)
|
|
17
|
+
expect(previousHref).toBeNull()
|
|
18
|
+
expect(link.rel).toBe('icon')
|
|
19
|
+
const found = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
20
|
+
expect(found).not.toBeNull()
|
|
21
|
+
expect(found!.href).toContain('fav.png')
|
|
22
|
+
expect(found!.type).toBe('image/png')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('updates the existing link in place and reports the previous href', () => {
|
|
26
|
+
const existing = document.createElement('link')
|
|
27
|
+
existing.rel = 'icon'
|
|
28
|
+
existing.href = 'https://cdn.example.com/old.png'
|
|
29
|
+
document.head.appendChild(existing)
|
|
30
|
+
|
|
31
|
+
const { created, previousHref, link } = setDocumentFavicon(
|
|
32
|
+
'https://cdn.example.com/new.svg',
|
|
33
|
+
'image/svg+xml',
|
|
34
|
+
)
|
|
35
|
+
expect(created).toBe(false)
|
|
36
|
+
expect(previousHref).toContain('old.png')
|
|
37
|
+
expect(link).toBe(existing)
|
|
38
|
+
expect(existing.href).toContain('new.svg')
|
|
39
|
+
expect(existing.type).toBe('image/svg+xml')
|
|
40
|
+
// Never duplicates the icon link.
|
|
41
|
+
expect(document.querySelectorAll('link[rel="icon"]').length).toBe(1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('preserves the existing type when no new type is provided', () => {
|
|
45
|
+
setDocumentFavicon('https://cdn.example.com/a.png', 'image/png')
|
|
46
|
+
setDocumentFavicon('https://cdn.example.com/b.png')
|
|
47
|
+
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
48
|
+
expect(link!.href).toContain('b.png')
|
|
49
|
+
expect(link!.type).toBe('image/png')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('exposes a stable brand-updated event name', () => {
|
|
53
|
+
expect(BRAND_UPDATED_EVENT).toBe('actuate:brand-updated')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { companionDimensionKeys } from '../../views/page-editor/SectionInspector.js'
|
|
3
|
+
|
|
4
|
+
describe('companionDimensionKeys', () => {
|
|
5
|
+
it('maps a *Url field to *Width / *Height (feature, hero)', () => {
|
|
6
|
+
expect(companionDimensionKeys('imageUrl')).toEqual({
|
|
7
|
+
width: 'imageWidth',
|
|
8
|
+
height: 'imageHeight',
|
|
9
|
+
})
|
|
10
|
+
expect(companionDimensionKeys('backgroundUrl')).toEqual({
|
|
11
|
+
width: 'backgroundWidth',
|
|
12
|
+
height: 'backgroundHeight',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('maps a gallery-row `url` field to plain width / height', () => {
|
|
17
|
+
expect(companionDimensionKeys('url')).toEqual({ width: 'width', height: 'height' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('falls back to <key>Width / <key>Height for other keys', () => {
|
|
21
|
+
expect(companionDimensionKeys('photo')).toEqual({ width: 'photoWidth', height: 'photoHeight' })
|
|
22
|
+
})
|
|
23
|
+
})
|
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
|
+
}
|
|
@@ -148,6 +148,7 @@ export function SectionInspector({
|
|
|
148
148
|
field={field}
|
|
149
149
|
value={section.content[field.key]}
|
|
150
150
|
onChange={(v) => onContentChange({ [field.key]: v })}
|
|
151
|
+
onPatch={(patch) => onContentChange(patch)}
|
|
151
152
|
/>
|
|
152
153
|
))}
|
|
153
154
|
</fieldset>
|
|
@@ -302,11 +303,17 @@ function ContentField({
|
|
|
302
303
|
field,
|
|
303
304
|
value,
|
|
304
305
|
onChange,
|
|
306
|
+
onPatch,
|
|
305
307
|
id: idProp,
|
|
306
308
|
}: {
|
|
307
309
|
field: SectionFieldDef
|
|
308
310
|
value: unknown
|
|
309
311
|
onChange: (v: unknown) => void
|
|
312
|
+
/**
|
|
313
|
+
* Optional multi-key patch sink — lets media fields persist companion
|
|
314
|
+
* dimension keys (width/height) atomically with the URL.
|
|
315
|
+
*/
|
|
316
|
+
onPatch?: (patch: Record<string, unknown>) => void
|
|
310
317
|
/** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
|
|
311
318
|
id?: string
|
|
312
319
|
}) {
|
|
@@ -335,7 +342,7 @@ function ContentField({
|
|
|
335
342
|
}
|
|
336
343
|
|
|
337
344
|
if (field.type === 'media') {
|
|
338
|
-
return <MediaField id={id} field={field} value={text} onChange={onChange} />
|
|
345
|
+
return <MediaField id={id} field={field} value={text} onChange={onChange} onPatch={onPatch} />
|
|
339
346
|
}
|
|
340
347
|
|
|
341
348
|
if (field.type === 'select') {
|
|
@@ -393,16 +400,39 @@ function ContentField({
|
|
|
393
400
|
)
|
|
394
401
|
}
|
|
395
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Companion content keys that hold an image field's intrinsic dimensions.
|
|
405
|
+
* Picking from the media library persists width/height alongside the URL so the
|
|
406
|
+
* public renderer can use the CLS-safe `next/image` path without waiting on
|
|
407
|
+
* read-time `/resolve` enrichment. Mirrors the keys the section renderers read:
|
|
408
|
+
* `imageUrl` → `imageWidth` / `imageHeight`; `url` (gallery row) → `width` / `height`.
|
|
409
|
+
*/
|
|
410
|
+
export function companionDimensionKeys(fieldKey: string): { width: string; height: string } {
|
|
411
|
+
if (fieldKey === 'url') return { width: 'width', height: 'height' }
|
|
412
|
+
if (fieldKey.endsWith('Url')) {
|
|
413
|
+
const base = fieldKey.slice(0, -'Url'.length)
|
|
414
|
+
return { width: `${base}Width`, height: `${base}Height` }
|
|
415
|
+
}
|
|
416
|
+
return { width: `${fieldKey}Width`, height: `${fieldKey}Height` }
|
|
417
|
+
}
|
|
418
|
+
|
|
396
419
|
function MediaField({
|
|
397
420
|
id,
|
|
398
421
|
field,
|
|
399
422
|
value,
|
|
400
423
|
onChange,
|
|
424
|
+
onPatch,
|
|
401
425
|
}: {
|
|
402
426
|
id: string
|
|
403
427
|
field: SectionFieldDef
|
|
404
428
|
value: string
|
|
405
429
|
onChange: (v: unknown) => void
|
|
430
|
+
/**
|
|
431
|
+
* When provided, picking from the library applies a multi-key patch (URL +
|
|
432
|
+
* intrinsic width/height) in one update. Falls back to `onChange` (URL only)
|
|
433
|
+
* for manual entry and when no patch sink is wired.
|
|
434
|
+
*/
|
|
435
|
+
onPatch?: (patch: Record<string, unknown>) => void
|
|
406
436
|
}) {
|
|
407
437
|
const [open, setOpen] = useState(false)
|
|
408
438
|
return (
|
|
@@ -434,8 +464,18 @@ function MediaField({
|
|
|
434
464
|
<MediaPickerModal
|
|
435
465
|
open={open}
|
|
436
466
|
onClose={() => setOpen(false)}
|
|
467
|
+
onSelectItem={(item) => {
|
|
468
|
+
if (!onPatch) return
|
|
469
|
+
const { width: wKey, height: hKey } = companionDimensionKeys(field.key)
|
|
470
|
+
const patch: Record<string, unknown> = { [field.key]: item.url }
|
|
471
|
+
if (typeof item.width === 'number') patch[wKey] = item.width
|
|
472
|
+
if (typeof item.height === 'number') patch[hKey] = item.height
|
|
473
|
+
onPatch(patch)
|
|
474
|
+
}}
|
|
437
475
|
onSelect={(url) => {
|
|
438
|
-
|
|
476
|
+
// onSelectItem handles persistence when a patch sink is wired; only
|
|
477
|
+
// fall back to a URL-only update when it isn't.
|
|
478
|
+
if (!onPatch) onChange(url)
|
|
439
479
|
setOpen(false)
|
|
440
480
|
}}
|
|
441
481
|
accept="image/*"
|
|
@@ -743,6 +783,7 @@ function SortableRepeaterRow({
|
|
|
743
783
|
field={sub}
|
|
744
784
|
value={row[sub.key]}
|
|
745
785
|
onChange={(v) => onPatch({ [sub.key]: v })}
|
|
786
|
+
onPatch={(patch) => onPatch(patch)}
|
|
746
787
|
/>
|
|
747
788
|
))}
|
|
748
789
|
</div>
|
|
@@ -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
|
|