From f69c86f737085a62af53f2de96bd126f2720402d Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 08:47:05 -0500 Subject: [PATCH] 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 && } + +
+ + +
+ ) +}