From 785090eb977483acb4ab14191f88fe0e2e3241c6 Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sun, 28 Jun 2026 08:40:52 -0500 Subject: [PATCH] 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%;