From 30c2a30c80e76e1069b062a6408fc90563a1a37b Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 02:08:21 -0500 Subject: [PATCH 1/6] Add hero canvas editor spec (corrected to current code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build contract for the WYSIWYG portal-hero editor on hero-feature, derived from the design doc and corrected against the codebase: - public settings is a whitelist (getPublic/PUBLIC_KEYS), so hero_layout must be added there — the doc's "no backend changes" was wrong - moon is the reusable MoonDot component; route vs nav live in App.jsx vs AdminLayout.jsx; admin content is 1000px (canvas scales to fit) Locked decisions: full v1, buttons as a first-class element type, pre-populate the current hero on first run, native Pointer Events for drag. Phased plan with per-phase exit checks. No schema change (JSON in settings). Co-Authored-By: Claude Opus 4.8 --- HERO_EDITOR.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 HERO_EDITOR.md diff --git a/HERO_EDITOR.md b/HERO_EDITOR.md new file mode 100644 index 0000000..d97153e --- /dev/null +++ b/HERO_EDITOR.md @@ -0,0 +1,119 @@ +# 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.** 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 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. +- **Phase 4 — moon + badge + image + resize + snap grid.** Remaining element types, + resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish. + +## 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). From 578bffc51ff499f16b97ea9f6cc435b198b14fda Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 02:18:09 -0500 Subject: [PATCH 2/6] Hero Phase 1: layout data path + portal renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of the hero canvas editor (see HERO_EDITOR.md). - settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it (corrects the design doc — public settings is a whitelist, not getAll(); hero_layout_draft stays admin-only) - new HeroElement.jsx: renders one layout element by type (text_block, buttons, moon, badge, image); absolute % positioning with anchor; shared by the portal now and the editor canvas later - MoonDot: optional color override for the hero moon element - Portal.jsx: parse hero_layout (version-checked, try/catch), render elements sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the page is byte-for-byte unchanged until staff publish their own No schema change. Verified: default render matches the old hero; publishing a hero_layout re-renders the portal; the draft key is not exposed publicly; client builds; no console errors. Co-Authored-By: Claude Opus 4.8 --- client/src/components/HeroElement.jsx | 139 ++++++++++++++++++++ client/src/components/MoonDot.jsx | 15 +-- client/src/routes/public/Portal.jsx | 117 +++++++++++----- server/src/model/settings/settings.model.js | 1 + 4 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 client/src/components/HeroElement.jsx diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx new file mode 100644 index 0000000..0352dfc --- /dev/null +++ b/client/src/components/HeroElement.jsx @@ -0,0 +1,139 @@ +import { Link } from 'react-router-dom' +import MoonDot from './MoonDot.jsx' + +// Font family tokens a line may opt into; default is the page serif. +const FONT = { display: 'var(--display)', sans: 'var(--sans)' } + +// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp() +// used by the pre-populated default so the hero stays responsive until edited). +function sizeToCss(v) { + return typeof v === 'number' ? `${v}px` : v +} + +function lineStyle(line) { + return { + display: 'block', // each line stacks (so a span line behaves like the others) + margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0', + fontFamily: FONT[line.font] || undefined, + fontSize: sizeToCss(line.fontSize), + color: line.color || 'inherit', + fontWeight: line.weight || undefined, + fontStyle: line.italic ? 'italic' : undefined, + letterSpacing: line.letterSpacing || undefined, + textTransform: line.transform || undefined, + lineHeight: line.lineHeight || undefined, + maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined, + marginLeft: line.maxWidth ? 'auto' : undefined, + marginRight: line.maxWidth ? 'auto' : undefined, + } +} + +function TextBlock({ props }) { + const align = props.align || 'center' + return ( +
+ {(props.lines || []).map((line, i) => { + const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p' + return ( + + {line.text} + + ) + })} +
+ ) +} + +function Buttons({ props }) { + const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center' + return ( +
+ {(props.items || []).map((b, i) => ( + + {b.label} + + ))} +
+ ) +} + +function Badge({ props }) { + return ( + + {props.text} + + ) +} + +function HeroImage({ props }) { + return ( + {props.alt + ) +} + +function content(element) { + switch (element.type) { + case 'text_block': + return + case 'buttons': + return + case 'moon': + return + case 'badge': + return + case 'image': + return + default: + return null + } +} + +// Absolute-positioned wrapper + type-specific content. `wrapperStyle` lets the +// editor layer on selection affordances without changing positioning logic. +export default function HeroElement({ element, wrapperStyle, children }) { + const anchor = element.anchor || 'center' + const transform = + anchor === 'center' + ? 'translate(-50%, -50%)' + : anchor === 'top-right' + ? 'translateX(-100%)' + : undefined + // text_block/buttons may set a box width (px); kept within the viewport on mobile. + const boxWidth = + (element.type === 'text_block' || element.type === 'buttons') && element.props?.width + ? `min(${element.props.width}px, calc(100vw - 36px))` + : undefined + return ( +
+ {content(element)} + {children} +
+ ) +} diff --git a/client/src/components/MoonDot.jsx b/client/src/components/MoonDot.jsx index f501643..28bff70 100644 --- a/client/src/components/MoonDot.jsx +++ b/client/src/components/MoonDot.jsx @@ -1,9 +1,8 @@ -// The little glowing moon used in the logo, login, and maintenance screens. -export default function MoonDot({ size = 13, glow = 0.45 }) { - return ( - - ) +// The little glowing moon used in the logo, login, maintenance screens, and the +// hero canvas. `color` overrides the radial-gradient start point (else the CSS +// .moon default is used). +export default function MoonDot({ size = 13, glow = 0.45, color }) { + const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` } + if (color) style.background = `radial-gradient(circle at 35% 30%, ${color}, #9fb0c6 55%, #5d6e88)` + return } diff --git a/client/src/routes/public/Portal.jsx b/client/src/routes/public/Portal.jsx index 0c8713f..5f9600f 100644 --- a/client/src/routes/public/Portal.jsx +++ b/client/src/routes/public/Portal.jsx @@ -1,9 +1,66 @@ +import { useMemo } from 'react' import { Link } from 'react-router-dom' import PublicLayout from '../../components/PublicLayout.jsx' +import HeroElement from '../../components/HeroElement.jsx' import { useSite } from '../../contexts/SiteContext.jsx' +const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png' const HERO_BG = - "linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('/assets/img/uomysticmoon-main-hero.png')" + "linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" + + DEFAULT_HERO_IMAGE + + "')" + +// Single-stop dark overlay driven by the editor's opacity slider. +function buildOverlay(opacity) { + return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)` +} + +// The current hardcoded hero, expressed as a HeroLayout so the page is unchanged +// until staff publish their own. Font sizes use the existing clamp() strings so +// the default stays responsive (editor-created text uses px). +function defaultLayout(teaser) { + return { + version: 1, + background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' }, + overlay: { opacity: 0.72 }, + elements: [ + { + id: 'default-text', + type: 'text_block', + x: 50, + y: 42, + z: 1, + anchor: 'center', + props: { + align: 'center', + width: 760, + lines: [ + { text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' }, + { text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 }, + { text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 }, + { text: teaser, tag: 'p', fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 }, + ], + }, + }, + { + id: 'default-buttons', + type: 'buttons', + x: 50, + y: 72, + z: 2, + anchor: 'center', + props: { + align: 'center', + gap: 12, + items: [ + { label: 'Enter the Website', to: '/site', variant: 'primary' }, + { label: 'Open the Wiki', to: '/wiki', variant: 'ghost' }, + ], + }, + }, + ], + } +} const QUICK = [ { label: 'News', to: '/site/news' }, @@ -34,47 +91,47 @@ export default function Portal() { settings.homepage_teaser || 'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.' + // Parse the published layout; fall back to the pre-populated default if missing, + // malformed, the wrong version, or empty — so the hero is never blank. + const parsed = useMemo(() => { + try { + const l = settings.hero_layout ? JSON.parse(settings.hero_layout) : null + return l && l.version === 1 && Array.isArray(l.elements) && l.elements.length ? l : null + } catch { + return null + } + }, [settings.hero_layout]) + + const layout = parsed || defaultLayout(teaser) + const isDefault = !parsed + const bg = layout.background || {} + // Keep the exact multi-gradient look for the untouched default; otherwise build + // from the editor's overlay opacity over the chosen (or default) image. + const backgroundImage = + isDefault && !bg.image_url + ? HERO_BG + : `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')` + const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0)) + return (
-
-

- Private shard project -

-

- UOMysticmoon -

-

- A private Ultima Online world in progress -

-

{teaser}

-
- - Enter the Website - - - Open the Wiki - -
+
+ {elements.map((el) => ( + + ))}
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 90dbde1..08a1fa9 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -8,6 +8,7 @@ const PUBLIC_KEYS = [ 'homepage_teaser', 'contact_email', 'site_title', + 'hero_layout', // portal hero composition (JSON). Draft key stays admin-only. ] async function get(key) { From ccad727ec37fb4de564f00ea9526d73b08e5ff3a Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 02:32:09 -0500 Subject: [PATCH 3/6] Hero Phase 2: editor shell + background/overlay + draft/preview/publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second phase of the hero canvas editor (see HERO_EDITOR.md). - new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/ parseLayout used by both the portal and the editor (Portal refactored onto it) - new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route): - live canvas preview (16:9) rendering the draft via HeroElement - background panel: image upload (/admin/uploads, >1MB warning), 3x3 position grid, overlay opacity slider — all update the canvas in real time - debounced (800ms) auto-save to hero_layout_draft - Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert - Portal: ?preview=1 renders the draft via the admin settings endpoint, with a "showing unpublished draft" banner; normal load renders the published layout No schema/dep changes. Verified end to end: overlay/position update the canvas, auto-save writes the draft, publish updates the live portal, preview shows the draft while the public page shows live. Element drag/properties land in Phase 3. Co-Authored-By: Claude Opus 4.8 --- HERO_EDITOR.md | 17 +- client/src/App.jsx | 2 + client/src/lib/heroLayout.js | 89 ++++++++ client/src/routes/admin/AdminLayout.jsx | 2 + client/src/routes/admin/views/HeroEditor.jsx | 216 +++++++++++++++++++ client/src/routes/public/Portal.jsx | 118 ++++------ 6 files changed, 355 insertions(+), 89 deletions(-) create mode 100644 client/src/lib/heroLayout.js create mode 100644 client/src/routes/admin/views/HeroEditor.jsx 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 +
+ )}
From 785090eb977483acb4ab14191f88fe0e2e3241c6 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 08:40:52 -0500 Subject: [PATCH 4/6] Hero Phase 3: element select / drag / edit (text_block + buttons) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third phase of the hero canvas editor (see HERO_EDITOR.md). - HeroElement: editor mode — inner content made non-interactive so the wrapper handles select/drag; selection outline; box width now canvas-relative (calc(100% - 36px)) so text blocks fit the smaller editor canvas - HeroEditor: element tray (+ Text / + Buttons), click-to-select, native Pointer Events drag (position as % of the canvas, clamped), Delete key + panel delete, z-order (send back / bring forward), and per-type property panels: - text_block: per-line text / tag / font size (px) / color / bold, add+remove lines, alignment - buttons: per-item label / path / variant, add+remove, alignment empty-canvas click deselects (back to the background panel) - theme.css: .hero-el-editable outline/hover/selected + grid helper Verified in-browser: selecting shows the line editor, editing updates the canvas live, drag repositions, add/delete and z-order work, deselect returns to the background panel; no console errors. Moon/badge/image + resize + snap are Phase 4. Co-Authored-By: Claude Opus 4.8 --- HERO_EDITOR.md | 10 +- client/src/components/HeroElement.jsx | 25 +- client/src/routes/admin/views/HeroEditor.jsx | 358 ++++++++++++++----- client/src/styles/theme.css | 19 + 4 files changed, 321 insertions(+), 91 deletions(-) diff --git a/HERO_EDITOR.md b/HERO_EDITOR.md index 8dbc36c..0a3f50e 100644 --- a/HERO_EDITOR.md +++ b/HERO_EDITOR.md @@ -108,9 +108,13 @@ layout adapts across viewports without breakpoint data. `version` is validated 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. +- **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.** Remaining element types, resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish. diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx index 0352dfc..9dfa88e 100644 --- a/client/src/components/HeroElement.jsx +++ b/client/src/components/HeroElement.jsx @@ -105,9 +105,17 @@ function content(element) { } } -// 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 }) { +// 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' @@ -115,13 +123,17 @@ export default function HeroElement({ element, wrapperStyle, children }) { : anchor === 'top-right' ? 'translateX(-100%)' : undefined - // text_block/buttons may set a box width (px); kept within the viewport on mobile. + // 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(100vw - 36px))` + ? `min(${element.props.width}px, calc(100% - 36px))` : undefined + const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ') return (
- {content(element)} +
{content(element)}
{children}
) diff --git a/client/src/routes/admin/views/HeroEditor.jsx b/client/src/routes/admin/views/HeroEditor.jsx index ea54501..39b5b64 100644 --- a/client/src/routes/admin/views/HeroEditor.jsx +++ b/client/src/routes/admin/views/HeroEditor.jsx @@ -7,17 +7,34 @@ import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLay 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' }] } } + } + return base +} + export default function HeroEditor() { - const [layout, setLayout] = useState(null) // working draft - const [live, setLive] = useState(null) // last published (for revert) + 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 teaserRef = useRef('') - const skipSave = useRef(true) // don't autosave right after load / revert + const skipSave = useRef(true) + const canvasRef = useRef(null) - // Load draft → live → default. useEffect(() => { let active = true api.admin @@ -37,7 +54,6 @@ export default function HeroEditor() { } }, []) - // Debounced auto-save to the draft key. useEffect(() => { if (!layout) return if (skipSave.current) { @@ -54,6 +70,20 @@ export default function HeroEditor() { 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 @@ -61,9 +91,54 @@ export default function HeroEditor() { 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 move = (ev) => { + const nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100) + const ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100) + 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) + } async function onUploadBg(e) { const file = e.target.files?.[0] @@ -82,11 +157,9 @@ export default function HeroEditor() { } 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 { @@ -97,40 +170,42 @@ export default function HeroEditor() { 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) + setSelectedId(null) + setLayout(live || defaultLayout(teaserRef.current)) await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {}) setStatus('Reverted to live') } return (
- {/* Toolbar */} -
+

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

- - - + + +
+ {/* Element tray */} +
+ Add: + + +
+
- {/* Canvas */}
{ + if (e.target === e.currentTarget) setSelectedId(null) + }} style={{ position: 'relative', width: '100%', @@ -142,75 +217,194 @@ export default function HeroEditor() { }} > {elements.map((el) => ( - + onElPointerDown(e, el)} + /> ))}

- Live preview of the draft. Element drag & properties arrive in the next phase. + Click to select · drag to move · Delete key removes the selected element.

- {/* Background / overlay panel */} -
) } + +// ── 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' && } + {!['text_block', 'buttons'].includes(element.type) && ( +

Editing controls for this element type arrive in the next phase.

+ )} + +
+ + + +
+ + ) +} + +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 + )} +
+
+ ))} + +
+ ) +} 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%; From f69c86f737085a62af53f2de96bd126f2720402d Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 08:47:05 -0500 Subject: [PATCH 5/6] Hero Phase 4: moon/badge/image elements + resize + snap grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete. - element tray adds moon, badge, and image; property panels: - moon: size / glow / color - badge: text / background / text color / corner radius - image: upload (/admin/uploads, >1MB warning) / width% / alt - corner resize handle on selected elements (image→width%, moon→size, text_block→box width) - 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on - HeroElement: image element shows an "Upload an image" placeholder until a source is set (a srcless image never ships live) Verified in-browser: all five element types add + edit; moon resized 64->104px via the handle; snap grid overlays; a published moon + badge render on the live portal; no console errors. Co-Authored-By: Claude Opus 4.8 --- HERO_EDITOR.md | 12 +- client/src/components/HeroElement.jsx | 11 ++ client/src/routes/admin/views/HeroEditor.jsx | 164 ++++++++++++++++++- 3 files changed, 179 insertions(+), 8 deletions(-) diff --git a/HERO_EDITOR.md b/HERO_EDITOR.md index 0a3f50e..931d003 100644 --- a/HERO_EDITOR.md +++ b/HERO_EDITOR.md @@ -115,8 +115,16 @@ layout adapts across viewports without breakpoint data. `version` is validated 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.** Remaining element types, - resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish. +- **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`. diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx index 9dfa88e..496719a 100644 --- a/client/src/components/HeroElement.jsx +++ b/client/src/components/HeroElement.jsx @@ -79,6 +79,17 @@ function Badge({ props }) { } function HeroImage({ props }) { + if (!props.src) { + // Editor placeholder until an image is chosen (a srcless image never ships live). + return ( +
+ Upload an image +
+ ) + } return ( { - const nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100) - const ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100) + 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 = () => { @@ -140,6 +152,37 @@ export default function HeroEditor() { 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 + let val + if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // % + else if (el.type === 'moon') val = clamp(orig + dxPx, 8, 400) // px + else val = clamp(orig + dxPx, 120, 1080) // 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 = '' @@ -197,6 +240,16 @@ export default function HeroEditor() { Add: + + + +
@@ -216,6 +269,9 @@ export default function HeroEditor() { ...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' }} + /> + )} + ))}

@@ -308,9 +372,9 @@ function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselec {element.type === 'text_block' && } {element.type === 'buttons' && } - {!['text_block', 'buttons'].includes(element.type) && ( -

Editing controls for this element type arrive in the next phase.

- )} + {element.type === 'moon' && } + {element.type === 'badge' && } + {element.type === 'image' && }
@@ -408,3 +472,91 @@ function ButtonsPanel({ element, onProps }) {
) } + +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 ( +
+
+ Image + {p.src && } + +
+ + +
+ ) +} From 73eac2a138053dbf70deb03ad2c2e9b10a516bcf Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 09:13:22 -0500 Subject: [PATCH 6/6] Hero editor: larger faithful canvas + real moon from the hero art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback that the editor was too small/cramped (elements overlapping) and that the generated CSS moon looked bad. - AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap) - HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the column, capped at ~66vh). Because viewport-unit fonts and % positions scale together, the canvas is now a faithful miniature of the live hero — the default text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid; resize math is scale-aware. - Moon element now renders the actual moon cropped from the hero artwork (client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS dot; MoonPanel exposes size + glow. Verified: at 1440px the canvas is ~785x442 with the panel beside it, default elements don't overlap, and the moon loads the real image. Build clean. Co-Authored-By: Claude Opus 4.8 --- client/public/assets/img/hero-moon.png | Bin 0 -> 50979 bytes client/src/components/HeroElement.jsx | 22 ++++- client/src/routes/admin/AdminLayout.jsx | 4 +- client/src/routes/admin/views/HeroEditor.jsx | 87 +++++++++++-------- 4 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 client/public/assets/img/hero-moon.png diff --git a/client/public/assets/img/hero-moon.png b/client/public/assets/img/hero-moon.png new file mode 100644 index 0000000000000000000000000000000000000000..94f54fa66de7e9c1e22be0d4ae7386ed365eafa4 GIT binary patch literal 50979 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
  • b>cvTEQpW8H<)C7=XyZ%8*`20QD+9^V%_nC> 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 sYtO&N9RHOuzVsW{*EQX case 'buttons': return - case 'moon': - return + case 'moon': { + const size = element.props?.size || 96 + const glow = element.props?.glow ?? 0.45 + return ( + + ) + } case 'badge': return case 'image': diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3cf61d1..4fd70c9 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -41,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). @@ -147,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 index 8b3972d..b287780 100644 --- a/client/src/routes/admin/views/HeroEditor.jsx +++ b/client/src/routes/admin/views/HeroEditor.jsx @@ -20,7 +20,7 @@ function newElement(type, z) { 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: 64, glow: 0.45, color: '#eef3f8' } } + 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 @@ -37,9 +37,27 @@ export default function HeroEditor() { 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) + 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 @@ -133,8 +151,8 @@ export default function HeroEditor() { } catch { /* ignore */ } - const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas - const stepY = (8 / rect.height) * 100 + 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) @@ -169,10 +187,11 @@ export default function HeroEditor() { } 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 + dxPx, 8, 400) // px - else val = clamp(orig + dxPx, 120, 1080) // text_block box px + 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 = () => { @@ -253,25 +272,27 @@ export default function HeroEditor() {
    -
    -
    { - if (e.target === e.currentTarget) setSelectedId(null) - }} - style={{ - position: 'relative', - width: '100%', - aspectRatio: '16 / 9', - borderRadius: 10, - overflow: 'hidden', - border: '1px solid var(--line)', - ...heroBackground(layout), - }} - > - {snap && ( -
    - )} +
    +
    +
    { + 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) => ( ))} +

    Click to select · drag to move · Delete key removes the selected element.

    -
    )