@reflagged/shell 1.7.1 → 1.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -6,7 +6,7 @@ import { loadAppConfig } from '../config'
6
6
  import { baseSearchUrl, isPaletteShortcut, matchApps, type PaletteApp } from '../lib/palette/palette'
7
7
  import { ImpersonationCapsule, type ImpersonationInfo } from './ImpersonationCapsule'
8
8
  import { ServiceIcon } from './ServiceIcon'
9
- import { useTenantTheme } from './TenantTheme'
9
+ import { faviconHref, useTenantFavicon, useTenantTheme } from './TenantTheme'
10
10
 
11
11
  type Booking = {
12
12
  id: string
@@ -226,6 +226,7 @@ export function BrandSwitcher() {
226
226
  }, [open])
227
227
 
228
228
  useTenantTheme(shell?.org?.themeCssUrl)
229
+ useTenantFavicon(faviconHref(shell?.org))
229
230
 
230
231
  const orgName = shell?.org?.name ?? null
231
232
  const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useState } from 'react'
4
4
 
5
5
  const THEME_LINK_ID = 'rflgd-tenant-theme'
6
+ const FAVICON_LINK_ID = 'rflgd-tenant-favicon'
6
7
 
7
8
  /**
8
9
  * Loads the tenant palette stylesheet from the platform once per page:
@@ -26,24 +27,97 @@ export function useTenantTheme(href: string | null | undefined): void {
26
27
  }, [href])
27
28
  }
28
29
 
30
+ /**
31
+ * Which stored image becomes the favicon.
32
+ *
33
+ * The mark is the square asset made for small sizes; the wide logo squeezed
34
+ * into 16×16 looks poor but still tells you which tab you are on. A tenant may
35
+ * have neither — then the tab stays blank rather than showing somebody else's
36
+ * logo, which is worse than no icon at all.
37
+ *
38
+ * Pure on purpose: the choice is the part worth testing, the DOM work below
39
+ * only repeats what `useTenantTheme` has been doing since 1.x.
40
+ */
41
+ export function faviconHref(
42
+ org: { logoMarkUrl?: string | null; logoUrl?: string | null } | null | undefined,
43
+ ): string | null {
44
+ return org?.logoMarkUrl?.trim() || org?.logoUrl?.trim() || null
45
+ }
46
+
47
+ /**
48
+ * Points the browser tab at the tenant's logo.
49
+ *
50
+ * Set from the client, because the modules share this package rather than a
51
+ * server layout: a per-app `icon.tsx` would mean the same file in every
52
+ * service. The cost is a blank tab for the moment it takes `/api/shell-info`
53
+ * to answer.
54
+ */
55
+ export function useTenantFavicon(href: string | null | undefined): void {
56
+ useEffect(() => {
57
+ if (!href) return
58
+
59
+ const apply = () => {
60
+ let link = document.getElementById(FAVICON_LINK_ID) as HTMLLinkElement | null
61
+ if (!link) {
62
+ link = document.createElement('link')
63
+ link.id = FAVICON_LINK_ID
64
+ link.rel = 'icon'
65
+ }
66
+ if (link.href !== href) link.href = href
67
+ // An app may ship icons of its own — the Payload admin does, one per
68
+ // colour scheme. Browsers take the last matching `rel=icon` in the head,
69
+ // and Next re-inserts its metadata links on every client navigation,
70
+ // after ours. Appending alone therefore holds only until the first
71
+ // in-app navigation, so the competitors go and ours is appended last.
72
+ for (const other of document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')) {
73
+ if (other !== link) other.remove()
74
+ }
75
+ document.head.appendChild(link)
76
+ }
77
+
78
+ apply()
79
+ // Guarded by the count: `apply` itself mutates the head, and without the
80
+ // check the observer would call it again on its own changes.
81
+ const observer = new MutationObserver(() => {
82
+ if (document.querySelectorAll('link[rel~="icon"]').length > 1) apply()
83
+ })
84
+ observer.observe(document.head, { childList: true })
85
+ return () => observer.disconnect()
86
+ }, [href])
87
+ }
88
+
29
89
  /**
30
90
  * For modules that do not mount `BrandSwitcher` (which loads the palette
31
91
  * itself): render once in the root layout.
32
92
  */
33
93
  export function TenantTheme(): null {
34
94
  const [href, setHref] = useState<string | null>(null)
95
+ const [icon, setIcon] = useState<string | null>(null)
35
96
  useEffect(() => {
36
97
  let cancel = false
37
98
  fetch('/api/shell-info', { credentials: 'include' })
38
99
  .then((r) => (r.ok ? r.json() : null))
39
- .then((d: { org?: { themeCssUrl?: string | null } | null } | null) => {
40
- if (!cancel) setHref(d?.org?.themeCssUrl ?? null)
41
- })
100
+ .then(
101
+ (
102
+ d: {
103
+ org?: {
104
+ themeCssUrl?: string | null
105
+ logoMarkUrl?: string | null
106
+ logoUrl?: string | null
107
+ } | null
108
+ } | null,
109
+ ) => {
110
+ if (cancel) return
111
+ setHref(d?.org?.themeCssUrl ?? null)
112
+ setIcon(faviconHref(d?.org))
113
+ },
114
+ )
42
115
  .catch(() => undefined)
43
116
  return () => {
44
117
  cancel = true
45
118
  }
46
119
  }, [])
47
120
  useTenantTheme(href)
121
+ useTenantFavicon(icon)
48
122
  return null
49
123
  }