@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.
@@ -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
- // Prefer the dark logo on dark backgrounds when supplied.
102
- const primary =
103
- resolvedTheme === 'dark' && adminBrand.darkUrl ? adminBrand.darkUrl : adminBrand.primaryUrl
104
- const mark = adminBrand.markUrl
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
- if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
109
- if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
110
- if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
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="h-8 w-auto shrink-0 object-contain" />
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 (configLogo) {
149
+ if (cfg) {
127
150
  return (
128
151
  <div className="flex min-w-0 items-center gap-2.5">
129
- <img src={configLogo} alt={alt} className="h-8 w-auto shrink-0 object-contain" />
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 src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
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
- {asset?.url ? (
83
+ {showPreview ? (
80
84
  // Never stretch/crop — contain preserves aspect ratio + transparency.
81
85
  <img
82
- src={asset.url}
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