Files
website/client/src/contexts/SiteContext.jsx
wtclaude 847cfd2d2b feat(theming): brand-asset overrides and a cached, settings-aware HTML shell
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:09:56 -05:00

92 lines
3.9 KiB
JavaScript

import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js'
import { applyThemeTokens } from '../lib/themeVars.js'
const SiteContext = createContext(null)
// Public site settings + mode (always reachable, even during maintenance).
export function SiteProvider({ children }) {
const [settings, setSettings] = useState({})
const [loading, setLoading] = useState(true)
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
// also goes false when the request failed and we fell back to {}. The boot
// theme handoff below turns on this distinction.
const [settled, setSettled] = useState(false)
const refresh = useCallback(async () => {
try {
const data = await api.publicSettings()
setSettings(data || {})
setSettled(true)
} catch {
setSettings({})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const brand = useMemo(() => settings.brand || {}, [settings])
// Apply the admin's theme. The whole effective token set is resolved
// server-side, so this only writes it and takes back what it wrote before —
// see lib/themeVars.js for why the removal half matters. No theme block means
// the admin never themed this instance, and the shipped :root stands.
const appliedTokens = useRef([])
useEffect(() => {
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
// Take over from the shell's boot block. The server injects the same tokens
// into <head> so a themed instance does not paint the shipped palette for a
// frame first (utils/htmlShell.js); from here on this effect is the
// authority, and leaving the block behind would mean a later reset removed
// the inline properties only to reveal the stale block underneath.
//
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
// leaves us with no theme at all, and dropping the block then would strip a
// themed instance back to the shipped palette for no reason.
if (settled) document.getElementById('theme-boot')?.remove()
}, [settings.theme, settled])
// Apply the instance accent color to the CSS variable the theme is built on,
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
// the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
// server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
// theme block rather than fighting it.
//
// Deliberately ordered after the theme effect and re-run on any theme change:
// resetting a theme removes --accent from the token map, and this has to be
// the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
// would drop to the stylesheet's default accent until the next reload.
useEffect(() => {
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
}, [brand.accent, settings.theme])
// Memoized so consumers don't re-render on every provider render (brand is a
// fresh object each render, which would otherwise churn the context value).
const value = useMemo(
() => ({
settings,
loading,
refresh,
brand,
mode: settings.site_mode || 'live',
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
contactEmail: brand.contactEmail || settings.contact_email || '',
heroImage: brand.hero || '/assets/img/runic-emblem.png',
}),
[settings, loading, refresh, brand],
)
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
}
export function useSite() {
const ctx = useContext(SiteContext)
if (!ctx) throw new Error('useSite must be used within SiteProvider')
return ctx
}