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>
92 lines
3.9 KiB
JavaScript
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
|
|
}
|