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 (
+
+ )
+}
+
+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) {