From 578bffc51ff499f16b97ea9f6cc435b198b14fda Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 02:18:09 -0500 Subject: [PATCH] Hero Phase 1: layout data path + portal renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/components/HeroElement.jsx | 139 ++++++++++++++++++++ client/src/components/MoonDot.jsx | 15 +-- client/src/routes/public/Portal.jsx | 117 +++++++++++----- server/src/model/settings/settings.model.js | 1 + 4 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 client/src/components/HeroElement.jsx diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx new file mode 100644 index 0000000..0352dfc --- /dev/null +++ b/client/src/components/HeroElement.jsx @@ -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 ( +
+ {(props.lines || []).map((line, i) => { + const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p' + return ( + + {line.text} + + ) + })} +
+ ) +} + +function Buttons({ props }) { + const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center' + return ( +
+ {(props.items || []).map((b, i) => ( + + {b.label} + + ))} +
+ ) +} + +function Badge({ props }) { + return ( + + {props.text} + + ) +} + +function HeroImage({ props }) { + return ( + {props.alt + ) +} + +function content(element) { + switch (element.type) { + case 'text_block': + return + case 'buttons': + return + case 'moon': + return + case 'badge': + return + case 'image': + return + 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 ( +
+ {content(element)} + {children} +
+ ) +} diff --git a/client/src/components/MoonDot.jsx b/client/src/components/MoonDot.jsx index f501643..28bff70 100644 --- a/client/src/components/MoonDot.jsx +++ b/client/src/components/MoonDot.jsx @@ -1,9 +1,8 @@ -// The little glowing moon used in the logo, login, and maintenance screens. -export default function MoonDot({ size = 13, glow = 0.45 }) { - return ( - - ) +// The little glowing moon used in the logo, login, maintenance screens, and the +// hero canvas. `color` overrides the radial-gradient start point (else the CSS +// .moon default is used). +export default function MoonDot({ size = 13, glow = 0.45, color }) { + const 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 } diff --git a/client/src/routes/public/Portal.jsx b/client/src/routes/public/Portal.jsx index 0c8713f..5f9600f 100644 --- a/client/src/routes/public/Portal.jsx +++ b/client/src/routes/public/Portal.jsx @@ -1,9 +1,66 @@ +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('/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 = [ { label: 'News', to: '/site/news' }, @@ -34,47 +91,47 @@ export default function Portal() { 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 (
-
-

- Private shard project -

-

- UOMysticmoon -

-

- A private Ultima Online world in progress -

-

{teaser}

-
- - Enter the Website - - - Open the Wiki - -
+
+ {elements.map((el) => ( + + ))}
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 90dbde1..08a1fa9 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -8,6 +8,7 @@ const PUBLIC_KEYS = [ 'homepage_teaser', 'contact_email', 'site_title', + 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. ] async function get(key) {