Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the first tenant that sets these vars rather than a special case in the code. Architecture (chosen because the app ships as a prebuilt image): - server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot, with Runic Gateway defaults. - Text/colors reach the SPA at RUNTIME through the existing public settings API (settings.model.getPublic -> SiteContext), so no client rebuild. The admin-editable site title + contact email still override BRAND_NAME/email. - SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime. - Express templates the built index.html <title>/description/OG/favicon at serve time from BRAND_* (renderIndexHtml in app.js). - Server-side consumers read brand directly: emails, TOTP issuer, API docs, boot logs, HTML error page. Bot uses it for embed color + logs. Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount (BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back to a built-in image when unset. Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*) and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/ rg_token). Production keeps its real values by pinning them in .env — see .env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity (proof the substitution works). Changing a deployed COOKIE_NAME invalidates existing sessions, so UOMysticmoon pins uomm_token. Verified: 193 server tests pass, client builds, app.js loads + templates the built index.html, brand transform injects title/description/OG/favicon.
71 lines
2.8 KiB
JavaScript
71 lines
2.8 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
|
import HeroElement from '../../components/HeroElement.jsx'
|
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
|
import { api } from '../../api/client.js'
|
|
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
|
|
|
|
// Admin "Preview" opens the portal with ?preview=1 to render the unpublished draft.
|
|
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
|
|
|
export default function Portal() {
|
|
const { settings, siteShortName, siteTitle, heroImage } = useSite()
|
|
const teaser =
|
|
settings.homepage_teaser ||
|
|
`${siteShortName} is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.`
|
|
|
|
// Published layout (public). Falls back to the pre-populated default if missing,
|
|
// malformed, the wrong version, or empty — so the hero is never blank.
|
|
const published = useMemo(() => parseLayout(settings.hero_layout), [settings.hero_layout])
|
|
|
|
// In preview mode, pull the draft via the admin endpoint (requires a logged-in
|
|
// admin cookie); falls back silently to the published/default layout otherwise.
|
|
const [draft, setDraft] = useState(null)
|
|
useEffect(() => {
|
|
if (!PREVIEW) return
|
|
let active = true
|
|
api.admin
|
|
.getSettings()
|
|
.then((s) => active && setDraft(parseLayout(s.hero_layout_draft)))
|
|
.catch(() => {})
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
|
const layout = active || defaultLayout(teaser, siteTitle)
|
|
const isDefault = !active
|
|
const bgStyle = heroBackground(layout, { isDefault, defaultImage: heroImage })
|
|
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
|
|
|
return (
|
|
<PublicLayout header={false}>
|
|
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
{PREVIEW && draft && (
|
|
<div
|
|
className="sans"
|
|
style={{ background: 'var(--accent)', color: 'var(--bg-deep)', textAlign: 'center', padding: '6px 12px', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.04em' }}
|
|
>
|
|
Preview — showing unpublished draft
|
|
</div>
|
|
)}
|
|
<section
|
|
style={{
|
|
position: 'relative',
|
|
flex: 1,
|
|
minHeight: '100vh',
|
|
...bgStyle,
|
|
}}
|
|
>
|
|
<div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
|
|
{elements.map((el) => (
|
|
<HeroElement key={el.id} element={el} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</PublicLayout>
|
|
)
|
|
}
|