diff --git a/HERO_EDITOR.md b/HERO_EDITOR.md index d97153e..8dbc36c 100644 --- a/HERO_EDITOR.md +++ b/HERO_EDITOR.md @@ -98,13 +98,16 @@ layout adapts across viewports without breakpoint data. `version` is validated ## 7. Phased build (each phase: build → verify in preview → commit) - **Phase 0 — Spec** ✅ this document. -- **Phase 1 — Data path & renderer.** Add `hero_layout` to the public whitelist; - `HeroElement.jsx`; Portal reads the layout and renders elements with a - `DEFAULT_LAYOUT` fallback (pre-populated current hero). *Exit:* portal looks - identical with no key set; setting `hero_layout` by hand re-renders the hero. -- **Phase 2 — Editor shell + background/overlay.** `/admin/hero` view + nav; canvas - preview; background image upload + 3×3 position + opacity slider; draft auto-save, - publish, preview, revert. *Exit:* change the background image WYSIWYG and publish. +- **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.** Add/select/move (native pointer)/delete/z-order; text_block + buttons property panels. *Exit:* add a heading + CTA row, drag to place, publish, see it live. 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/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..3cf61d1 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', diff --git a/client/src/routes/admin/views/HeroEditor.jsx b/client/src/routes/admin/views/HeroEditor.jsx new file mode 100644 index 0000000..ea54501 --- /dev/null +++ b/client/src/routes/admin/views/HeroEditor.jsx @@ -0,0 +1,216 @@ +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'] + +export default function HeroEditor() { + const [layout, setLayout] = useState(null) // working draft + const [live, setLive] = useState(null) // last published (for revert) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [uploading, setUploading] = useState(false) + const teaserRef = useRef('') + const skipSave = useRef(true) // don't autosave right after load / revert + + // Load draft → live → default. + 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 + } + }, []) + + // Debounced auto-save to the draft key. + 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]) + + 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 patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } })) + const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } })) + + 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() { + // Force-save the draft so the new tab shows the latest. + 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 + const base = live || defaultLayout(teaserRef.current) + skipSave.current = true + setLayout(base) + await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {}) + setStatus('Reverted to live') + } + + return ( +
+ {/* Toolbar */} +
+

+ Compose the portal hero. {status && · {status}} +

+
+ + + +
+
+ +
+ {/* Canvas */} +
+
+ {elements.map((el) => ( + + ))} +
+

+ Live preview of the draft. Element drag & properties arrive in the next phase. +

+
+ + {/* Background / overlay panel */} + +
+
+ ) +} diff --git a/client/src/routes/public/Portal.jsx b/client/src/routes/public/Portal.jsx index 5f9600f..01f526b 100644 --- a/client/src/routes/public/Portal.jsx +++ b/client/src/routes/public/Portal.jsx @@ -1,66 +1,10 @@ -import { useMemo } from 'react' +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 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('" + - 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' }, - ], - }, - }, - ], - } -} +import { api } from '../../api/client.js' +import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js' const QUICK = [ { label: 'News', to: '/site/news' }, @@ -85,47 +29,57 @@ 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.' - // Parse the published layout; fall back to the pre-populated default if missing, + // 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 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 published = useMemo(() => parseLayout(settings.hero_layout), [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}')` + // 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 +
+ )}