diff --git a/HERO_EDITOR.md b/HERO_EDITOR.md
new file mode 100644
index 0000000..931d003
--- /dev/null
+++ b/HERO_EDITOR.md
@@ -0,0 +1,134 @@
+# UOMysticmoon — Hero Canvas Editor Spec
+
+> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
+> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
+> to match the current codebase** and with the open questions resolved.
+> Same workflow as the wiki upgrade: design → phased build → verify.
+
+## 1. Goal
+
+Let staff compose the portal hero (background image, overlay opacity, and floating
+elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
+publish — no source edits. Layout persists as JSON in the existing `settings` table.
+
+## 2. Locked decisions
+
+| # | Decision |
+|---|---|
+| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
+| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
+| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
+| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
+| Font size | Stored in **px** (fixed reference canvas) |
+| Image compression | **None** server-side; client warns when a file is > ~1 MB |
+| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
+| Other pages | Out of scope for v1 (design allows a per-page key later) |
+
+## 3. Corrections to the design doc (current-code reality)
+
+1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
+ → `settings.getPublic()` → `PUBLIC_KEYS` in
+ [settings.model.js](server/src/model/settings/settings.model.js). The doc's
+ "no backend changes / picked up automatically" is wrong. **Fix:** add
+ `hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
+ (admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
+2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
+ props `size`/`glow`), used in logo/login/maintenance — not "only the header."
+ The `moon` element reuses it; it gains an optional `color`.
+3. **Route vs. nav live in different files.** `/admin/hero` route →
+ [App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
+ [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
+4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
+ scaled-to-fit; percentage positions stay faithful.
+
+Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
+in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
+arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
+columns — no schema change).
+
+## 4. Data model — no schema change
+
+Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
+
+```jsonc
+{
+ "version": 1,
+ "background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
+ "overlay": { "opacity": 0.72 },
+ "elements": [
+ { "id": "uuid", "type": "text_block|buttons|moon|badge|image",
+ "x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
+ ]
+}
+```
+
+Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
+layout adapts across viewports without breakpoint data. `version` is validated
+(`=== 1`) before use; anything else falls back.
+
+### Element props
+
+| Type | Props |
+|---|---|
+| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
+| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
+| `moon` | `size`, `glow`, `color` |
+| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
+| `image` | `src`, `width`(%), `alt` |
+
+## 5. Backend changes
+- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
+ layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
+
+## 6. Frontend changes
+- **New** `client/src/components/HeroElement.jsx` — renders one element by type
+ (shared by the live portal and the editor canvas).
+- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
+ properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
+ auto-save draft, preview, publish, revert.
+- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
+ (or draft when `?preview=1` + admin), render elements, fall back to a
+ `DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
+- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
+ [App.jsx](client/src/App.jsx) (route `/admin/hero`).
+- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
+- **No** `client/src/api/client.js` changes needed beyond what exists
+ (`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
+
+## 7. Phased build (each phase: build → verify in preview → commit)
+
+- **Phase 0 — Spec** ✅ this document.
+- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
+ whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
+ fallback. Default render matches the old hero; publishing a layout re-renders;
+ draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
+- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
+ `/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
+ position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
+ reads the draft (admin) with a banner; revert. Verified: overlay/position update
+ the canvas, auto-save writes the draft, publish writes live, preview shows the
+ draft while the normal portal shows live.
+- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
+ 2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
+ native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
+ (send back / bring forward); text_block line editor (text/tag/size/color/bold,
+ add/remove lines, align) and buttons editor (label/path/variant, add/remove).
+ Verified: select shows the line editor, editing a line updates the canvas live,
+ drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
+- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
+ Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
+ text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
+ moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
+ until a file is chosen. Verified: each type adds + edits, resize moved a moon
+ 64→104px, snap grid shows, and a published moon+badge render on the live portal.
+
+**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
+Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
+handle for now), per-viewport layouts, server-side image compression.
+
+## 8. Edge cases (from the doc, carried forward)
+- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
+- Element ids via `crypto.randomUUID()` (never array index).
+- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
+- Last-write-wins on concurrent admin edits (acceptable for this shard).
+- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).
diff --git a/client/public/assets/img/hero-moon.png b/client/public/assets/img/hero-moon.png
new file mode 100644
index 0000000..94f54fa
Binary files /dev/null and b/client/public/assets/img/hero-moon.png differ
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 83bb776..1829be4 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -23,6 +23,7 @@ import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
+import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
@@ -66,6 +67,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx
new file mode 100644
index 0000000..7d12d8d
--- /dev/null
+++ b/client/src/components/HeroElement.jsx
@@ -0,0 +1,179 @@
+import { Link } from 'react-router-dom'
+
+const MOON_IMAGE = '/assets/img/hero-moon.png'
+
+// 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 }) {
+ if (!props.src) {
+ // Editor placeholder until an image is chosen (a srcless image never ships live).
+ return (
+
+ Upload an image
+
+ )
+ }
+ return (
+
+ )
+}
+
+function content(element) {
+ switch (element.type) {
+ case 'text_block':
+ return
+ case 'buttons':
+ return
+ case 'moon': {
+ const size = element.props?.size || 96
+ const glow = element.props?.glow ?? 0.45
+ return (
+
+ )
+ }
+ case 'badge':
+ return
+ case 'image':
+ return
+ default:
+ return null
+ }
+}
+
+// Absolute-positioned wrapper + type-specific content. In `editor` mode the inner
+// content is made non-interactive (so clicks select/drag the wrapper) and the
+// wrapper takes selection styling + an onPointerDown handler.
+export default function HeroElement({
+ element,
+ wrapperStyle,
+ editor = false,
+ selected = false,
+ onPointerDown,
+ 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 containing block
+ // (the hero section live, or the editor canvas) with small side gutters.
+ const boxWidth =
+ (element.type === 'text_block' || element.type === 'buttons') && element.props?.width
+ ? `min(${element.props.width}px, calc(100% - 36px))`
+ : undefined
+ const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ')
+ 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/lib/heroLayout.js b/client/src/lib/heroLayout.js
new file mode 100644
index 0000000..3ff659e
--- /dev/null
+++ b/client/src/lib/heroLayout.js
@@ -0,0 +1,89 @@
+// Shared hero-layout helpers used by the public portal and the admin editor.
+
+export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
+
+// The original hand-tuned multi-gradient hero background (used only for the
+// untouched default so the live page is byte-for-byte unchanged until edited).
+export 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.
+export function buildOverlay(opacity) {
+ return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
+}
+
+// Background style for a layout. When `isDefault` and no custom image is set, use
+// the exact original gradient stack; otherwise compose the overlay over the image.
+export function heroBackground(layout, { isDefault = false } = {}) {
+ const bg = layout.background || {}
+ const backgroundImage =
+ isDefault && !bg.image_url
+ ? HERO_BG
+ : `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
+ return {
+ backgroundColor: 'var(--bg-deep)',
+ backgroundImage,
+ backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
+ backgroundRepeat: 'no-repeat',
+ backgroundSize: bg.size || 'cover',
+ }
+}
+
+// Parse a stored layout string; return null if missing/malformed/wrong version.
+export function parseLayout(str) {
+ try {
+ const l = str ? JSON.parse(str) : null
+ return l && l.version === 1 && Array.isArray(l.elements) ? l : null
+ } catch {
+ return null
+ }
+}
+
+// The current hardcoded hero 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).
+export 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' },
+ ],
+ },
+ },
+ ],
+ }
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 9b62075..4fd70c9 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -8,6 +8,7 @@ const NAV = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/posts', label: 'Posts' },
{ to: '/admin/wiki', label: 'Wiki' },
+ { to: '/admin/hero', label: 'Hero Editor' },
{ to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' },
{ to: '/admin/users', label: 'Users' },
@@ -17,6 +18,7 @@ const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
'/admin/wiki': 'Wiki Pages',
+ '/admin/hero': 'Hero Editor',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/users': 'Users',
@@ -39,6 +41,8 @@ export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || 'Admin'
+ // The hero canvas editor needs room — let it use the full content width.
+ const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
@@ -145,7 +149,7 @@ export default function AdminLayout() {
-
+
diff --git a/client/src/routes/admin/views/HeroEditor.jsx b/client/src/routes/admin/views/HeroEditor.jsx
new file mode 100644
index 0000000..b287780
--- /dev/null
+++ b/client/src/routes/admin/views/HeroEditor.jsx
@@ -0,0 +1,581 @@
+import { useEffect, useRef, useState } from 'react'
+import HeroElement from '../../../components/HeroElement.jsx'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLayout.js'
+
+const POS_Y = ['top', 'center', 'bottom']
+const POS_X = ['left', 'center', 'right']
+
+const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
+const round2 = (v) => Math.round(v * 100) / 100
+const genId = () => (crypto.randomUUID ? crypto.randomUUID() : `el-${Date.now()}-${Math.random()}`)
+const hexOf = (v) => (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v || '') ? v : '#ffffff')
+
+function newElement(type, z) {
+ const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
+ if (type === 'text_block') {
+ return { ...base, props: { align: 'center', width: 600, lines: [{ text: 'New heading', tag: 'h2', fontSize: 36, color: '#ffffff', weight: 600 }] } }
+ }
+ if (type === 'buttons') {
+ return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
+ }
+ if (type === 'moon') return { ...base, props: { size: 96, glow: 0.5 } }
+ if (type === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } }
+ if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
+ return base
+}
+
+const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
+
+export default function HeroEditor() {
+ const [layout, setLayout] = useState(null)
+ const [live, setLive] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [status, setStatus] = useState('')
+ const [uploading, setUploading] = useState(false)
+ const [selectedId, setSelectedId] = useState(null)
+ const [snap, setSnap] = useState(false)
+ const [scale, setScale] = useState(1)
+ const teaserRef = useRef('')
+ const skipSave = useRef(true)
+ const canvasRef = useRef(null) // the 1280x720 stage (scaled to fit)
+ const colRef = useRef(null) // measures available width
+
+ // Render the canvas as a scaled 1280x720 stage so it's a faithful miniature of
+ // the live hero (viewport-unit fonts + % positions all scale together).
+ useEffect(() => {
+ const el = colRef.current
+ if (!el) return
+ const recompute = () => setScale(Math.min(el.clientWidth / 1280, (window.innerHeight * 0.66) / 720))
+ recompute()
+ const ro = new ResizeObserver(recompute)
+ ro.observe(el)
+ window.addEventListener('resize', recompute)
+ return () => {
+ ro.disconnect()
+ window.removeEventListener('resize', recompute)
+ }
+ }, [loading])
+
+ useEffect(() => {
+ let active = true
+ api.admin
+ .getSettings()
+ .then((s) => {
+ if (!active) return
+ teaserRef.current = s.homepage_teaser || ''
+ const liveL = parseLayout(s.hero_layout)
+ setLive(liveL)
+ skipSave.current = true
+ setLayout(parseLayout(s.hero_layout_draft) || liveL || defaultLayout(teaserRef.current))
+ })
+ .catch(() => active && setError('Could not load hero settings.'))
+ .finally(() => active && setLoading(false))
+ return () => {
+ active = false
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!layout) return
+ if (skipSave.current) {
+ skipSave.current = false
+ return
+ }
+ setStatus('Saving…')
+ const t = setTimeout(() => {
+ api.admin
+ .updateSettings({ hero_layout_draft: JSON.stringify(layout) })
+ .then(() => setStatus('Draft saved'))
+ .catch(() => setStatus('Save failed'))
+ }, 800)
+ return () => clearTimeout(t)
+ }, [layout])
+
+ // Delete key removes the selected element (unless typing in a field).
+ useEffect(() => {
+ if (!selectedId) return
+ const onKey = (e) => {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return
+ e.preventDefault()
+ setLayout((l) => ({ ...l, elements: l.elements.filter((el) => el.id !== selectedId) }))
+ setSelectedId(null)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [selectedId])
+
+ if (loading) return
+ if (error) return
+ if (!layout) return null
+
+ const bg = layout.background || {}
+ const overlay = layout.overlay?.opacity ?? 0.72
+ const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
+ const selected = layout.elements.find((e) => e.id === selectedId) || null
+
+ const patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
+ const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } }))
+ const updateElement = (id, patch) =>
+ setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, ...patch } : e)) }))
+ const updateProps = (id, patch) =>
+ setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, props: { ...e.props, ...patch } } : e)) }))
+ const removeElement = (id) => {
+ setLayout((l) => ({ ...l, elements: l.elements.filter((e) => e.id !== id) }))
+ setSelectedId(null)
+ }
+ const bumpZ = (id, dir) =>
+ setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, z: Math.max(0, (e.z || 0) + dir) } : e)) }))
+ const addElement = (type) => {
+ const z = Math.max(0, ...layout.elements.map((e) => e.z || 0)) + 1
+ const el = newElement(type, z)
+ setLayout((l) => ({ ...l, elements: [...l.elements, el] }))
+ setSelectedId(el.id)
+ }
+
+ function onElPointerDown(e, el) {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ setSelectedId(el.id)
+ const rect = canvasRef.current.getBoundingClientRect()
+ const sx = e.clientX
+ const sy = e.clientY
+ const ox = el.x
+ const oy = el.y
+ const node = e.currentTarget
+ try {
+ node.setPointerCapture(e.pointerId)
+ } catch {
+ /* ignore */
+ }
+ const stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as %
+ const stepY = (8 / 720) * 100
+ const move = (ev) => {
+ let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
+ let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
+ if (snap) {
+ nx = Math.round(nx / stepX) * stepX
+ ny = Math.round(ny / stepY) * stepY
+ }
+ updateElement(el.id, { x: round2(nx), y: round2(ny) })
+ }
+ const up = () => {
+ node.removeEventListener('pointermove', move)
+ node.removeEventListener('pointerup', up)
+ }
+ node.addEventListener('pointermove', move)
+ node.addEventListener('pointerup', up)
+ }
+
+ // Corner-handle resize: adjusts the type-appropriate dimension.
+ function onResizePointerDown(e, el) {
+ if (e.button !== 0) return
+ e.stopPropagation()
+ const dim = RESIZABLE[el.type]
+ if (!dim) return
+ const rect = canvasRef.current.getBoundingClientRect()
+ const sx = e.clientX
+ const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
+ const node = e.currentTarget
+ try {
+ node.setPointerCapture(e.pointerId)
+ } catch {
+ /* ignore */
+ }
+ const move = (ev) => {
+ const dxPx = ev.clientX - sx
+ const dxLogical = dxPx / scale // client px → stage px
+ let val
+ if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
+ else if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
+ else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
+ updateProps(el.id, { [dim]: Math.round(val) })
+ }
+ const up = () => {
+ node.removeEventListener('pointermove', move)
+ node.removeEventListener('pointerup', up)
+ }
+ node.addEventListener('pointermove', move)
+ node.addEventListener('pointerup', up)
+ }
+
+ async function onUploadBg(e) {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (!file) return
+ if (file.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
+ setUploading(true)
+ try {
+ const { url } = await api.admin.upload(file)
+ patchBg({ image_url: url })
+ } catch (err) {
+ setStatus(err.message || 'Upload failed')
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ async function preview() {
+ await api.admin.updateSettings({ hero_layout_draft: JSON.stringify(layout) }).catch(() => {})
+ window.open('/?preview=1', '_blank', 'noopener')
+ }
+ async function publish() {
+ const json = JSON.stringify(layout)
+ try {
+ await api.admin.updateSettings({ hero_layout: json, hero_layout_draft: json })
+ setLive(layout)
+ setStatus('Published ✓')
+ } catch (err) {
+ setStatus(err.message || 'Publish failed')
+ }
+ }
+ async function revert() {
+ if (!confirm('Discard draft changes and revert to the live hero?')) return
+ skipSave.current = true
+ setSelectedId(null)
+ setLayout(live || defaultLayout(teaserRef.current))
+ await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
+ setStatus('Reverted to live')
+ }
+
+ return (
+
+
+
+ Compose the portal hero. {status && · {status}}
+
+
+
+
+
+
+
+
+ {/* Element tray */}
+
+ Add:
+
+
+
+
+
+
+
+
+
+
+
+
{
+ if (e.target === e.currentTarget) setSelectedId(null)
+ }}
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: 1280,
+ height: 720,
+ transformOrigin: 'top left',
+ transform: `scale(${scale})`,
+ ...heroBackground(layout),
+ }}
+ >
+ {snap && (
+
+ )}
+ {elements.map((el) => (
+
onElPointerDown(e, el)}
+ >
+ {el.id === selectedId && RESIZABLE[el.type] && (
+ onResizePointerDown(e, el)}
+ title="Resize"
+ style={{ position: 'absolute', right: -6, bottom: -6, width: 14, height: 14, borderRadius: 3, background: 'var(--accent)', border: '1px solid var(--bg-deep)', cursor: 'nwse-resize', pointerEvents: 'auto' }}
+ />
+ )}
+
+ ))}
+
+
+
+ Click to select · drag to move · Delete key removes the selected element.
+
+
+
+
+
+
+ )
+}
+
+// ── Background / overlay panel (no element selected) ────────────────────
+function BackgroundPanel({ bg, overlay, uploading, onUpload, patchBg, setOpacity }) {
+ return (
+ <>
+
Background & overlay
+
+
Background image
+ {bg.image_url ? (
+
+

+
+
+ ) : (
+
Using the default hero image.
+ )}
+
+
+
+
Background position
+
+ {POS_Y.map((py) =>
+ POS_X.map((px) => {
+ const activePos = (bg.position_x || 'left') === px && (bg.position_y || 'center') === py
+ return (
+
+
+
+ Overlay darkness — {Math.round(overlay * 100)}%
+ setOpacity(Number(e.target.value))} style={{ width: '100%' }} />
+
+ >
+ )
+}
+
+// ── Per-element properties ──────────────────────────────────────────────
+function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselect }) {
+ return (
+ <>
+
+
{element.type.replace('_', ' ')}
+
Done
+
+
+ {element.type === 'text_block' &&
}
+ {element.type === 'buttons' &&
}
+ {element.type === 'moon' &&
}
+ {element.type === 'badge' &&
}
+ {element.type === 'image' &&
}
+
+
+
+
+
+
+ >
+ )
+}
+
+const ALIGNS = ['left', 'center', 'right']
+
+function AlignField({ value, onChange }) {
+ return (
+
+ )
+}
+
+function TextBlockPanel({ element, onProps }) {
+ const lines = element.props?.lines || []
+ const setLine = (i, patch) => onProps({ lines: lines.map((l, idx) => (idx === i ? { ...l, ...patch } : l)) })
+ const addLine = () => onProps({ lines: [...lines, { text: 'New line', tag: 'p', fontSize: 18, color: '#dbe2ea', weight: 400 }] })
+ const removeLine = (i) => onProps({ lines: lines.filter((_, idx) => idx !== i) })
+
+ return (
+
+
onProps({ align: v })} />
+ {lines.map((line, i) => (
+
+
setLine(i, { text: e.target.value })} placeholder="Text" />
+
+
+ { const n = parseInt(e.target.value, 10); setLine(i, { fontSize: Number.isFinite(n) ? n : undefined }) }}
+ placeholder="px"
+ style={{ width: 70 }}
+ />
+ setLine(i, { color: e.target.value })} style={{ width: 40, height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }} title="Color" />
+
+
+
+ {lines.length > 1 && (
+ removeLine(i)}>Remove line
+ )}
+
+
+ ))}
+
+
+ )
+}
+
+function ButtonsPanel({ element, onProps }) {
+ const items = element.props?.items || []
+ const setItem = (i, patch) => onProps({ items: items.map((it, idx) => (idx === i ? { ...it, ...patch } : it)) })
+ const addItem = () => onProps({ items: [...items, { label: 'Button', to: '/', variant: 'primary' }] })
+ const removeItem = (i) => onProps({ items: items.filter((_, idx) => idx !== i) })
+
+ return (
+
+
onProps({ align: v })} />
+ {items.map((it, i) => (
+
+ ))}
+
+
+ )
+}
+
+const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }
+
+function MoonPanel({ element, onProps }) {
+ const p = element.props || {}
+ return (
+
+ )
+}
+
+function BadgePanel({ element, onProps }) {
+ const p = element.props || {}
+ return (
+
+ )
+}
+
+function ImagePanel({ element, onProps }) {
+ const p = element.props || {}
+ const [up, setUp] = useState(false)
+ async function onFile(e) {
+ const f = e.target.files?.[0]
+ e.target.value = ''
+ if (!f) return
+ if (f.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
+ setUp(true)
+ try {
+ const { url } = await api.admin.upload(f)
+ onProps({ src: url })
+ } catch {
+ /* ignore */
+ } finally {
+ setUp(false)
+ }
+ }
+ return (
+
+ )
+}
diff --git a/client/src/routes/public/Portal.jsx b/client/src/routes/public/Portal.jsx
index 0c8713f..01f526b 100644
--- a/client/src/routes/public/Portal.jsx
+++ b/client/src/routes/public/Portal.jsx
@@ -1,9 +1,10 @@
+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'
-
-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')"
+import { api } from '../../api/client.js'
+import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
const QUICK = [
{ label: 'News', to: '/site/news' },
@@ -28,53 +29,63 @@ const DESTINATIONS = [
},
]
+// 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 (
+ {PREVIEW && draft && (
+
+ Preview — showing unpublished draft
+
+ )}
-
-
- Private shard project
-
-
- UOMysticmoon
-
-
- A private Ultima Online world in progress
-
-
{teaser}
-
-
- Enter the Website
-
-
- Open the Wiki
-
-
+
+ {elements.map((el) => (
+
+ ))}
diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css
index b93dc4b..8948a0c 100644
--- a/client/src/styles/theme.css
+++ b/client/src/styles/theme.css
@@ -527,6 +527,25 @@ button[disabled] {
text-decoration: line-through;
}
+/* ===== Hero canvas editor ===== */
+.hero-el-editable {
+ outline: 1px dashed rgba(127, 153, 189, 0.45);
+ outline-offset: 2px;
+ user-select: none;
+ touch-action: none; /* let Pointer Events drive drag on touch */
+}
+.hero-el-editable:hover {
+ outline-color: var(--accent);
+}
+.hero-el-editable.is-selected {
+ outline: 2px solid var(--accent);
+}
+.hero-canvas-grid {
+ background-image:
+ linear-gradient(to right, rgba(127, 153, 189, 0.18) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(127, 153, 189, 0.18) 1px, transparent 1px);
+}
+
/* ===== Admin tables ===== */
.adm-table {
width: 100%;
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) {