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>
79 lines
2.8 KiB
JavaScript
79 lines
2.8 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'
|
|
|
|
// Centered card layout shared by the player login / register pages. `subtitle`
|
|
// labels the card; `footer` is optional content under the card (e.g. cross-links).
|
|
export default function PlayerShell({ subtitle, children, footer }) {
|
|
const { siteTitle, heroImage } = useSite()
|
|
const bg = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
|
|
return (
|
|
<main
|
|
style={{
|
|
minHeight: '100vh',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
padding: '40px 18px',
|
|
overflow: 'hidden',
|
|
backgroundColor: 'var(--bg-deep)',
|
|
backgroundImage: bg,
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: 'min(60vh, 520px)',
|
|
}}
|
|
>
|
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
|
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
|
<div style={{ marginBottom: 14 }}>
|
|
{/* Stacked above the moon rather than beside it: this layout is
|
|
centered text, and a flex row here would change the block's
|
|
height on instances with no logo. */}
|
|
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
|
<MoonDot size={15} glow={0.55} />
|
|
</div>
|
|
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
|
{siteTitle}
|
|
</h1>
|
|
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
|
{subtitle}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
border: '1px solid var(--line)',
|
|
borderRadius: 12,
|
|
padding: 28,
|
|
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
|
|
backdropFilter: 'blur(6px)',
|
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{footer}
|
|
|
|
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
|
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
|
← Back to site
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
// Off-screen honeypot styling (matches the admin login): present for bots, never
|
|
// seen or filled by real users. Name must equal the server HONEYPOT_FIELD.
|
|
export const honeypotStyle = {
|
|
position: 'absolute',
|
|
left: '-9999px',
|
|
top: 'auto',
|
|
width: '1px',
|
|
height: '1px',
|
|
opacity: 0,
|
|
pointerEvents: 'none',
|
|
}
|