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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user