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:
2026-06-28 08:47:05 -05:00
parent 785090eb97
commit f69c86f737
3 changed files with 179 additions and 8 deletions

View File

@@ -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 (
<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 (
<img
src={props.src}

View File

@@ -20,9 +20,14 @@ 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 === '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
}
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
export default function HeroEditor() {
const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null)
@@ -31,6 +36,7 @@ export default function HeroEditor() {
const [status, setStatus] = useState('')
const [uploading, setUploading] = useState(false)
const [selectedId, setSelectedId] = useState(null)
const [snap, setSnap] = useState(false)
const teaserRef = useRef('')
const skipSave = useRef(true)
const canvasRef = useRef(null)
@@ -127,9 +133,15 @@ export default function HeroEditor() {
} catch {
/* ignore */
}
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas
const stepY = (8 / rect.height) * 100
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)
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() {
<span className="field-label" style={{ margin: 0 }}>Add:</span>
<button onClick={() => addElement('text_block')} className="pill">+ Text</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 style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -216,6 +269,9 @@ export default function HeroEditor() {
...heroBackground(layout),
}}
>
{snap && (
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '8px 8px', pointerEvents: 'none', zIndex: 0 }} />
)}
{elements.map((el) => (
<HeroElement
key={el.id}
@@ -223,7 +279,15 @@ export default function HeroEditor() {
editor
selected={el.id === selectedId}
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>
<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 === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
{!['text_block', 'buttons'].includes(element.type) && (
<p className="sans dim" style={{ fontSize: '0.82rem' }}>Editing controls for this element type arrive in the next phase.</p>
)}
{element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
{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 }}>
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
@@ -408,3 +472,91 @@ function ButtonsPanel({ element, onProps }) {
</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>
)
}