Files
website/client/src/routes/public/Portal.jsx
whitlocktech ccad727ec3 Hero Phase 2: editor shell + background/overlay + draft/preview/publish
Second phase of the hero canvas editor (see HERO_EDITOR.md).

- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
  parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
  - live canvas preview (16:9) rendering the draft via HeroElement
  - background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
    grid, overlay opacity slider — all update the canvas in real time
  - debounced (800ms) auto-save to hero_layout_draft
  - Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
  "showing unpublished draft" banner; normal load renders the published layout

No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:32:09 -05:00

124 lines
4.6 KiB
JavaScript

import { useEffect, useMemo, useState } 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'
import { api } from '../../api/client.js'
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
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',
},
]
// 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 } = 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.'
// 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)
const isDefault = !active
const bgStyle = heroBackground(layout, { isDefault })
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',
minHeight: 'clamp(600px,72vh,860px)',
overflow: 'hidden',
...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>
<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>
)
}