Files
website/client/src/routes/public/Maintenance.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

63 lines
2.5 KiB
JavaScript

import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
export default function Maintenance() {
const { settings, contactEmail, siteShortName, heroImage } = useSite()
const heroBg = `linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('${heroImage}')`
const message =
settings.maintenance_message ||
`${siteShortName} is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.`
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
alignContent: 'center',
justifyItems: 'center',
textAlign: 'center',
padding: '80px max(18px,calc((100% - 760px)/2))',
overflow: 'hidden',
backgroundColor: 'var(--bg-deep)',
backgroundImage: heroBg,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'min(60vh, 520px)',
}}
>
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
<div style={{ marginBottom: 26 }}>
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
<MoonDot size={18} glow={0.6} />
</div>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
Building beneath the moon
</p>
<h1
className="display"
style={{ margin: 0, fontSize: 'clamp(2.6rem,7vw,4.6rem)', lineHeight: 1.05, letterSpacing: '0.02em' }}
>
The world is not yet open
</h1>
<p style={{ maxWidth: 520, margin: '24px auto 0', color: '#cdd6e0', fontSize: '1.14rem' }}>{message}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
<Link to="/site/news" className="btn btn-primary">
Read the news
</Link>
<a href={`mailto:${contactEmail}`} className="btn btn-ghost">
Contact us
</a>
</div>
<p className="sans" style={{ margin: '40px 0 0', color: '#7a8696', fontSize: '0.82rem' }}>
{contactEmail} &nbsp;·&nbsp;{' '}
<Link to="/admin/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Admin
</Link>
</p>
</div>
</main>
)
}