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 ( + {props.alt + ) +} + +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) => ( +
+ setItem(i, { label: e.target.value })} placeholder="Label" /> + setItem(i, { to: e.target.value })} placeholder="/path" style={{ fontFamily: 'ui-monospace,Menlo,monospace' }} /> +
+ + {items.length > 1 && ( + removeItem(i)}>Remove + )} +
+
+ ))} + +
+ ) +} + +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 ( +
+

Uses the moon from the hero artwork.

+ + +
+ ) +} + +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 ( +
+
+ Image + {p.src && } + +
+ + +
+ ) +} 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) {