Hero Phase 1: layout data path + portal renderer
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>
This commit is contained in:
139
client/src/components/HeroElement.jsx
Normal file
139
client/src/components/HeroElement.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import MoonDot from './MoonDot.jsx'
|
||||||
|
|
||||||
|
// Font family tokens a line may opt into; default is the page serif.
|
||||||
|
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
||||||
|
|
||||||
|
// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp()
|
||||||
|
// used by the pre-populated default so the hero stays responsive until edited).
|
||||||
|
function sizeToCss(v) {
|
||||||
|
return typeof v === 'number' ? `${v}px` : v
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStyle(line) {
|
||||||
|
return {
|
||||||
|
display: 'block', // each line stacks (so a span line behaves like the others)
|
||||||
|
margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0',
|
||||||
|
fontFamily: FONT[line.font] || undefined,
|
||||||
|
fontSize: sizeToCss(line.fontSize),
|
||||||
|
color: line.color || 'inherit',
|
||||||
|
fontWeight: line.weight || undefined,
|
||||||
|
fontStyle: line.italic ? 'italic' : undefined,
|
||||||
|
letterSpacing: line.letterSpacing || undefined,
|
||||||
|
textTransform: line.transform || undefined,
|
||||||
|
lineHeight: line.lineHeight || undefined,
|
||||||
|
maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined,
|
||||||
|
marginLeft: line.maxWidth ? 'auto' : undefined,
|
||||||
|
marginRight: line.maxWidth ? 'auto' : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextBlock({ props }) {
|
||||||
|
const align = props.align || 'center'
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||||
|
{(props.lines || []).map((line, i) => {
|
||||||
|
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
|
||||||
|
return (
|
||||||
|
<Tag key={i} style={lineStyle(line)}>
|
||||||
|
{line.text}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Buttons({ props }) {
|
||||||
|
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
|
||||||
|
{(props.items || []).map((b, i) => (
|
||||||
|
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
||||||
|
{b.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ props }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: props.bgColor || 'rgba(11,22,48,0.6)',
|
||||||
|
color: props.textColor || '#c2d2e6',
|
||||||
|
borderRadius: props.borderRadius ?? 999,
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroImage({ props }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt={props.alt || ''}
|
||||||
|
style={{ width: `${props.width || 40}%`, height: 'auto', display: 'block', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function content(element) {
|
||||||
|
switch (element.type) {
|
||||||
|
case 'text_block':
|
||||||
|
return <TextBlock props={element.props || {}} />
|
||||||
|
case 'buttons':
|
||||||
|
return <Buttons props={element.props || {}} />
|
||||||
|
case 'moon':
|
||||||
|
return <MoonDot size={element.props?.size || 48} glow={element.props?.glow ?? 0.45} color={element.props?.color} />
|
||||||
|
case 'badge':
|
||||||
|
return <Badge props={element.props || {}} />
|
||||||
|
case 'image':
|
||||||
|
return <HeroImage props={element.props || {}} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute-positioned wrapper + type-specific content. `wrapperStyle` lets the
|
||||||
|
// editor layer on selection affordances without changing positioning logic.
|
||||||
|
export default function HeroElement({ element, wrapperStyle, children }) {
|
||||||
|
const anchor = element.anchor || 'center'
|
||||||
|
const transform =
|
||||||
|
anchor === 'center'
|
||||||
|
? 'translate(-50%, -50%)'
|
||||||
|
: anchor === 'top-right'
|
||||||
|
? 'translateX(-100%)'
|
||||||
|
: undefined
|
||||||
|
// text_block/buttons may set a box width (px); kept within the viewport on mobile.
|
||||||
|
const boxWidth =
|
||||||
|
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width
|
||||||
|
? `min(${element.props.width}px, calc(100vw - 36px))`
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${element.x}%`,
|
||||||
|
top: `${element.y}%`,
|
||||||
|
zIndex: element.z || 0,
|
||||||
|
transform,
|
||||||
|
width: boxWidth,
|
||||||
|
...wrapperStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content(element)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// The little glowing moon used in the logo, login, and maintenance screens.
|
// The little glowing moon used in the logo, login, maintenance screens, and the
|
||||||
export default function MoonDot({ size = 13, glow = 0.45 }) {
|
// hero canvas. `color` overrides the radial-gradient start point (else the CSS
|
||||||
return (
|
// .moon default is used).
|
||||||
<span
|
export default function MoonDot({ size = 13, glow = 0.45, color }) {
|
||||||
className="moon"
|
const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }
|
||||||
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
|
if (color) style.background = `radial-gradient(circle at 35% 30%, ${color}, #9fb0c6 55%, #5d6e88)`
|
||||||
/>
|
return <span className="moon" style={style} />
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,66 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import HeroElement from '../../components/HeroElement.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
||||||
const HERO_BG =
|
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('/assets/img/uomysticmoon-main-hero.png')"
|
"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 = [
|
const QUICK = [
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
@@ -34,47 +91,47 @@ export default function Portal() {
|
|||||||
settings.homepage_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.'
|
'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 (
|
return (
|
||||||
<PublicLayout header={false}>
|
<PublicLayout header={false}>
|
||||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'grid',
|
|
||||||
alignContent: 'center',
|
|
||||||
minHeight: 'clamp(600px,72vh,860px)',
|
minHeight: 'clamp(600px,72vh,860px)',
|
||||||
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: 'var(--bg-deep)',
|
backgroundColor: 'var(--bg-deep)',
|
||||||
backgroundImage: HERO_BG,
|
backgroundImage,
|
||||||
backgroundPosition: 'left center',
|
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: bg.size || 'cover',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
<div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
|
||||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}>
|
{elements.map((el) => (
|
||||||
Private shard project
|
<HeroElement key={el.id} element={el} />
|
||||||
</p>
|
))}
|
||||||
<h1
|
|
||||||
className="display"
|
|
||||||
style={{ margin: 0, fontSize: 'clamp(3rem,8.5vw,5.75rem)', lineHeight: 1, letterSpacing: '0.02em' }}
|
|
||||||
>
|
|
||||||
UOMysticmoon
|
|
||||||
</h1>
|
|
||||||
<p style={{ margin: '22px auto 0', color: '#dbe2ea', fontSize: '1.32rem', fontStyle: 'italic' }}>
|
|
||||||
A private Ultima Online world in progress
|
|
||||||
</p>
|
|
||||||
<p style={{ maxWidth: 600, margin: '22px auto 0', color: '#c4cdd8', fontSize: '1.06rem' }}>{teaser}</p>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
|
|
||||||
<Link to="/site" className="btn btn-primary">
|
|
||||||
Enter the Website
|
|
||||||
</Link>
|
|
||||||
<Link to="/wiki" className="btn btn-ghost">
|
|
||||||
Open the Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const PUBLIC_KEYS = [
|
|||||||
'homepage_teaser',
|
'homepage_teaser',
|
||||||
'contact_email',
|
'contact_email',
|
||||||
'site_title',
|
'site_title',
|
||||||
|
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||||
]
|
]
|
||||||
|
|
||||||
async function get(key) {
|
async function get(key) {
|
||||||
|
|||||||
Reference in New Issue
Block a user