Hero Phase 4: moon/badge/image elements + resize + snap grid
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
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,
|
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.
|
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
|
||||||
- **Phase 4 — moon + badge + image + resize + snap grid.** Remaining element types,
|
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
|
||||||
resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish.
|
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)
|
## 8. Edge cases (from the doc, carried forward)
|
||||||
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ function Badge({ props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeroImage({ props }) {
|
function HeroImage({ props }) {
|
||||||
|
if (!props.src) {
|
||||||
|
// Editor placeholder until an image is chosen (a srcless image never ships live).
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ width: 160, height: 100, display: 'grid', placeItems: 'center', border: '1px dashed var(--accent)', borderRadius: 8, color: 'var(--muted)', fontSize: '0.8rem', background: 'rgba(11,22,48,0.4)' }}
|
||||||
|
>
|
||||||
|
Upload an image
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={props.src}
|
src={props.src}
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ function newElement(type, z) {
|
|||||||
if (type === 'buttons') {
|
if (type === 'buttons') {
|
||||||
return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
|
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 === '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
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
|
||||||
|
|
||||||
export default function HeroEditor() {
|
export default function HeroEditor() {
|
||||||
const [layout, setLayout] = useState(null)
|
const [layout, setLayout] = useState(null)
|
||||||
const [live, setLive] = useState(null)
|
const [live, setLive] = useState(null)
|
||||||
@@ -31,6 +36,7 @@ export default function HeroEditor() {
|
|||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [snap, setSnap] = useState(false)
|
||||||
const teaserRef = useRef('')
|
const teaserRef = useRef('')
|
||||||
const skipSave = useRef(true)
|
const skipSave = useRef(true)
|
||||||
const canvasRef = useRef(null)
|
const canvasRef = useRef(null)
|
||||||
@@ -127,9 +133,15 @@ export default function HeroEditor() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas
|
||||||
|
const stepY = (8 / rect.height) * 100
|
||||||
const move = (ev) => {
|
const move = (ev) => {
|
||||||
const nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
|
let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
|
||||||
const ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 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) })
|
updateElement(el.id, { x: round2(nx), y: round2(ny) })
|
||||||
}
|
}
|
||||||
const up = () => {
|
const up = () => {
|
||||||
@@ -140,6 +152,37 @@ export default function HeroEditor() {
|
|||||||
node.addEventListener('pointerup', up)
|
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) {
|
async function onUploadBg(e) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
@@ -197,6 +240,16 @@ export default function HeroEditor() {
|
|||||||
<span className="field-label" style={{ margin: 0 }}>Add:</span>
|
<span className="field-label" style={{ margin: 0 }}>Add:</span>
|
||||||
<button onClick={() => addElement('text_block')} className="pill">+ Text</button>
|
<button onClick={() => addElement('text_block')} className="pill">+ Text</button>
|
||||||
<button onClick={() => addElement('buttons')} className="pill">+ Buttons</button>
|
<button onClick={() => addElement('buttons')} className="pill">+ Buttons</button>
|
||||||
|
<button onClick={() => addElement('moon')} className="pill">+ Moon</button>
|
||||||
|
<button onClick={() => addElement('badge')} className="pill">+ Badge</button>
|
||||||
|
<button onClick={() => addElement('image')} className="pill">+ Image</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSnap((v) => !v)}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 'auto', borderColor: snap ? 'var(--accent)' : 'var(--line)', color: snap ? 'var(--accent)' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
Snap grid: {snap ? 'on' : 'off'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
@@ -216,6 +269,9 @@ export default function HeroEditor() {
|
|||||||
...heroBackground(layout),
|
...heroBackground(layout),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{snap && (
|
||||||
|
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '8px 8px', pointerEvents: 'none', zIndex: 0 }} />
|
||||||
|
)}
|
||||||
{elements.map((el) => (
|
{elements.map((el) => (
|
||||||
<HeroElement
|
<HeroElement
|
||||||
key={el.id}
|
key={el.id}
|
||||||
@@ -223,7 +279,15 @@ export default function HeroEditor() {
|
|||||||
editor
|
editor
|
||||||
selected={el.id === selectedId}
|
selected={el.id === selectedId}
|
||||||
onPointerDown={(e) => onElPointerDown(e, el)}
|
onPointerDown={(e) => onElPointerDown(e, el)}
|
||||||
/>
|
>
|
||||||
|
{el.id === selectedId && RESIZABLE[el.type] && (
|
||||||
|
<div
|
||||||
|
onPointerDown={(e) => 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' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HeroElement>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||||
@@ -308,9 +372,9 @@ function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselec
|
|||||||
|
|
||||||
{element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />}
|
{element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />}
|
||||||
{element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
|
{element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
|
||||||
{!['text_block', 'buttons'].includes(element.type) && (
|
{element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
|
||||||
<p className="sans dim" style={{ fontSize: '0.82rem' }}>Editing controls for this element type arrive in the next phase.</p>
|
{element.type === 'badge' && <BadgePanel element={element} onProps={onProps} />}
|
||||||
)}
|
{element.type === 'image' && <ImagePanel element={element} onProps={onProps} />}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}>
|
||||||
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
|
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
|
||||||
@@ -408,3 +472,91 @@ function ButtonsPanel({ element, onProps }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Size — {p.size || 64}px</span>
|
||||||
|
<input type="range" min="8" max="160" step="1" value={p.size || 64} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Glow — {Math.round((p.glow ?? 0.45) * 100)}%</span>
|
||||||
|
<input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.45} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Color</span>
|
||||||
|
<input type="color" value={hexOf(p.color)} onChange={(e) => onProps({ color: e.target.value })} style={{ ...swatch, width: 48 }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgePanel({ element, onProps }) {
|
||||||
|
const p = element.props || {}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Text</span>
|
||||||
|
<input className="input" value={p.text || ''} onChange={(e) => onProps({ text: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Background</span>
|
||||||
|
<input type="color" value={hexOf(p.bgColor)} onChange={(e) => onProps({ bgColor: e.target.value })} style={swatch} />
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Text color</span>
|
||||||
|
<input type="color" value={hexOf(p.textColor)} onChange={(e) => onProps({ textColor: e.target.value })} style={swatch} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Corner radius — {Math.min(p.borderRadius ?? 999, 24)}px</span>
|
||||||
|
<input type="range" min="0" max="24" step="1" value={Math.min(p.borderRadius ?? 999, 24)} onChange={(e) => onProps({ borderRadius: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Image</span>
|
||||||
|
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
|
||||||
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
||||||
|
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
|
||||||
|
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Width — {p.width || 40}%</span>
|
||||||
|
<input type="range" min="10" max="100" step="1" value={p.width || 40} onChange={(e) => onProps({ width: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Alt text</span>
|
||||||
|
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user