First phase of the hero canvas editor (see HERO_EDITOR.md). - settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it (corrects the design doc — public settings is a whitelist, not getAll(); hero_layout_draft stays admin-only) - new HeroElement.jsx: renders one layout element by type (text_block, buttons, moon, badge, image); absolute % positioning with anchor; shared by the portal now and the editor canvas later - MoonDot: optional color override for the hero moon element - Portal.jsx: parse hero_layout (version-checked, try/catch), render elements sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the page is byte-for-byte unchanged until staff publish their own No schema change. Verified: default render matches the old hero; publishing a hero_layout re-renders the portal; the draft key is not exposed publicly; client builds; no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.2 KiB
JavaScript
170 lines
6.2 KiB
JavaScript
import { useMemo } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
|
import HeroElement from '../../components/HeroElement.jsx'
|
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
|
|
|
const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
|
const HERO_BG =
|
|
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" +
|
|
DEFAULT_HERO_IMAGE +
|
|
"')"
|
|
|
|
// Single-stop dark overlay driven by the editor's opacity slider.
|
|
function buildOverlay(opacity) {
|
|
return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
|
|
}
|
|
|
|
// The current hardcoded hero, expressed as a HeroLayout so the page is unchanged
|
|
// until staff publish their own. Font sizes use the existing clamp() strings so
|
|
// the default stays responsive (editor-created text uses px).
|
|
function defaultLayout(teaser) {
|
|
return {
|
|
version: 1,
|
|
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
|
overlay: { opacity: 0.72 },
|
|
elements: [
|
|
{
|
|
id: 'default-text',
|
|
type: 'text_block',
|
|
x: 50,
|
|
y: 42,
|
|
z: 1,
|
|
anchor: 'center',
|
|
props: {
|
|
align: 'center',
|
|
width: 760,
|
|
lines: [
|
|
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
|
{ text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
|
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
|
{ text: teaser, tag: 'p', fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'default-buttons',
|
|
type: 'buttons',
|
|
x: 50,
|
|
y: 72,
|
|
z: 2,
|
|
anchor: 'center',
|
|
props: {
|
|
align: 'center',
|
|
gap: 12,
|
|
items: [
|
|
{ label: 'Enter the Website', to: '/site', variant: 'primary' },
|
|
{ label: 'Open the Wiki', to: '/wiki', variant: 'ghost' },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const QUICK = [
|
|
{ label: 'News', to: '/site/news' },
|
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
|
{ label: 'Monthly Newsletter', to: '/site/newsletter' },
|
|
{ label: 'About', to: '/site/about' },
|
|
]
|
|
|
|
const DESTINATIONS = [
|
|
{
|
|
kicker: 'Public portal',
|
|
title: 'Mysticmoon Website',
|
|
body: 'Updates, screenshots, newsletters, and weekly community posts from the shard.',
|
|
to: '/site',
|
|
},
|
|
{
|
|
kicker: 'Knowledge base',
|
|
title: 'Mysticmoon Wiki',
|
|
body: 'Guides, maps, systems, items, monsters, crafting, lore, and rules.',
|
|
to: '/wiki',
|
|
},
|
|
]
|
|
|
|
export default function Portal() {
|
|
const { settings } = useSite()
|
|
const teaser =
|
|
settings.homepage_teaser ||
|
|
'Mysticmoon 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.'
|
|
|
|
// Parse the published layout; fall back to the pre-populated default if missing,
|
|
// malformed, the wrong version, or empty — so the hero is never blank.
|
|
const parsed = useMemo(() => {
|
|
try {
|
|
const l = settings.hero_layout ? JSON.parse(settings.hero_layout) : null
|
|
return l && l.version === 1 && Array.isArray(l.elements) && l.elements.length ? l : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}, [settings.hero_layout])
|
|
|
|
const layout = parsed || defaultLayout(teaser)
|
|
const isDefault = !parsed
|
|
const bg = layout.background || {}
|
|
// Keep the exact multi-gradient look for the untouched default; otherwise build
|
|
// from the editor's overlay opacity over the chosen (or default) image.
|
|
const backgroundImage =
|
|
isDefault && !bg.image_url
|
|
? HERO_BG
|
|
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
|
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' }}>
|
|
<section
|
|
style={{
|
|
position: 'relative',
|
|
minHeight: 'clamp(600px,72vh,860px)',
|
|
overflow: 'hidden',
|
|
backgroundColor: 'var(--bg-deep)',
|
|
backgroundImage,
|
|
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: bg.size || 'cover',
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<div className="shell" style={{ padding: '56px 0 12px' }}>
|
|
<nav className="grid-2" aria-label="Main destinations">
|
|
{DESTINATIONS.map((d) => (
|
|
<Link key={d.to} to={d.to} className="card" style={{ padding: 30 }}>
|
|
<span className="card-kicker" style={{ letterSpacing: '0.16em', marginBottom: 14 }}>
|
|
{d.kicker}
|
|
</span>
|
|
<strong
|
|
className="display"
|
|
style={{ fontSize: '1.7rem', color: 'var(--head)', marginBottom: 10, fontWeight: 600 }}
|
|
>
|
|
{d.title}
|
|
</strong>
|
|
<span className="muted">{d.body}</span>
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="shell" style={{ padding: '24px 0 64px' }}>
|
|
<nav style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10 }} aria-label="Quick links">
|
|
{QUICK.map((q) => (
|
|
<Link key={q.to} to={q.to} className="pill">
|
|
{q.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</main>
|
|
</PublicLayout>
|
|
)
|
|
}
|