Hero Phase 3: element select / drag / edit (text_block + buttons)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 08:40:52 -05:00
parent ccad727ec3
commit 785090eb97
4 changed files with 321 additions and 91 deletions

View File

@@ -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 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 the canvas, auto-save writes the draft, publish writes live, preview shows the
draft while the normal portal shows live. draft while the normal portal shows live.
- **Phase 3 — Elements: select / drag / text_block / buttons.** Add/select/move - **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
(native pointer)/delete/z-order; text_block + buttons property panels. *Exit:* 2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
add a heading + CTA row, drag to place, publish, see it live. 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, - **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. resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish.

View File

@@ -105,9 +105,17 @@ function content(element) {
} }
} }
// Absolute-positioned wrapper + type-specific content. `wrapperStyle` lets the // Absolute-positioned wrapper + type-specific content. In `editor` mode the inner
// editor layer on selection affordances without changing positioning logic. // content is made non-interactive (so clicks select/drag the wrapper) and the
export default function HeroElement({ element, wrapperStyle, children }) { // 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 anchor = element.anchor || 'center'
const transform = const transform =
anchor === 'center' anchor === 'center'
@@ -115,13 +123,17 @@ export default function HeroElement({ element, wrapperStyle, children }) {
: anchor === 'top-right' : anchor === 'top-right'
? 'translateX(-100%)' ? 'translateX(-100%)'
: undefined : 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 = const boxWidth =
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width (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 : undefined
const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ')
return ( return (
<div <div
className={cls || undefined}
onPointerDown={onPointerDown}
style={{ style={{
position: 'absolute', position: 'absolute',
left: `${element.x}%`, left: `${element.x}%`,
@@ -129,10 +141,11 @@ export default function HeroElement({ element, wrapperStyle, children }) {
zIndex: element.z || 0, zIndex: element.z || 0,
transform, transform,
width: boxWidth, width: boxWidth,
cursor: editor ? 'move' : undefined,
...wrapperStyle, ...wrapperStyle,
}} }}
> >
{content(element)} <div style={editor ? { pointerEvents: 'none' } : undefined}>{content(element)}</div>
{children} {children}
</div> </div>
) )

View File

@@ -7,17 +7,34 @@ import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLay
const POS_Y = ['top', 'center', 'bottom'] const POS_Y = ['top', 'center', 'bottom']
const POS_X = ['left', 'center', 'right'] 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() { export default function HeroEditor() {
const [layout, setLayout] = useState(null) // working draft const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null) // last published (for revert) const [live, setLive] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [selectedId, setSelectedId] = useState(null)
const teaserRef = useRef('') 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(() => { useEffect(() => {
let active = true let active = true
api.admin api.admin
@@ -37,7 +54,6 @@ export default function HeroEditor() {
} }
}, []) }, [])
// Debounced auto-save to the draft key.
useEffect(() => { useEffect(() => {
if (!layout) return if (!layout) return
if (skipSave.current) { if (skipSave.current) {
@@ -54,6 +70,20 @@ export default function HeroEditor() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [layout]) }, [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 <Loading /> if (loading) return <Loading />
if (error) return <ErrorState message={error} /> if (error) return <ErrorState message={error} />
if (!layout) return null if (!layout) return null
@@ -61,9 +91,54 @@ export default function HeroEditor() {
const bg = layout.background || {} const bg = layout.background || {}
const overlay = layout.overlay?.opacity ?? 0.72 const overlay = layout.overlay?.opacity ?? 0.72
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0)) 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 patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } })) 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) { async function onUploadBg(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@@ -82,11 +157,9 @@ export default function HeroEditor() {
} }
async function preview() { 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(() => {}) await api.admin.updateSettings({ hero_layout_draft: JSON.stringify(layout) }).catch(() => {})
window.open('/?preview=1', '_blank', 'noopener') window.open('/?preview=1', '_blank', 'noopener')
} }
async function publish() { async function publish() {
const json = JSON.stringify(layout) const json = JSON.stringify(layout)
try { try {
@@ -97,40 +170,42 @@ export default function HeroEditor() {
setStatus(err.message || 'Publish failed') setStatus(err.message || 'Publish failed')
} }
} }
async function revert() { async function revert() {
if (!confirm('Discard draft changes and revert to the live hero?')) return if (!confirm('Discard draft changes and revert to the live hero?')) return
const base = live || defaultLayout(teaserRef.current)
skipSave.current = true skipSave.current = true
setLayout(base) setSelectedId(null)
setLayout(live || defaultLayout(teaserRef.current))
await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {}) await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
setStatus('Reverted to live') setStatus('Reverted to live')
} }
return ( return (
<section> <section>
{/* Toolbar */} <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 18 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}> <p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Compose the portal hero. {status && <span style={{ color: 'var(--accent)' }}>· {status}</span>} Compose the portal hero. {status && <span style={{ color: 'var(--accent)' }}>· {status}</span>}
</p> </p>
<div style={{ display: 'flex', gap: 10 }}> <div style={{ display: 'flex', gap: 10 }}>
<button onClick={revert} className="pill"> <button onClick={revert} className="pill">Revert to live</button>
Revert to live <button onClick={preview} className="pill">Preview </button>
</button> <button onClick={publish} className="btn btn-primary btn-sq">Publish</button>
<button onClick={preview} className="pill">
Preview
</button>
<button onClick={publish} className="btn btn-primary btn-sq">
Publish
</button>
</div> </div>
</div> </div>
{/* Element tray */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<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>
</div>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* Canvas */}
<div style={{ flex: '1 1 460px', minWidth: 320 }}> <div style={{ flex: '1 1 460px', minWidth: 320 }}>
<div <div
ref={canvasRef}
onPointerDown={(e) => {
if (e.target === e.currentTarget) setSelectedId(null)
}}
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
@@ -142,75 +217,194 @@ export default function HeroEditor() {
}} }}
> >
{elements.map((el) => ( {elements.map((el) => (
<HeroElement key={el.id} element={el} /> <HeroElement
key={el.id}
element={el}
editor
selected={el.id === selectedId}
onPointerDown={(e) => onElPointerDown(e, el)}
/>
))} ))}
</div> </div>
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}> <p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
Live preview of the draft. Element drag &amp; properties arrive in the next phase. Click to select · drag to move · Delete key removes the selected element.
</p> </p>
</div> </div>
{/* Background / overlay panel */} <aside className="panel-flat" style={{ flex: '0 0 300px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
<aside className="panel-flat" style={{ flex: '0 0 290px', padding: 18, display: 'flex', flexDirection: 'column', gap: 18 }}> {selected ? (
<div> <ElementPanel
<span className="field-label">Background image</span> key={selected.id}
{bg.image_url ? ( element={selected}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> onProps={(patch) => updateProps(selected.id, patch)}
<img src={bg.image_url} alt="" style={{ width: 70, height: 44, objectFit: 'cover', borderRadius: 6, border: '1px solid var(--line)' }} /> onRemove={() => removeElement(selected.id)}
<button onClick={() => patchBg({ image_url: null })} className="pill" style={{ fontSize: '0.8rem' }}> onForward={() => bumpZ(selected.id, 1)}
Clear onBack={() => bumpZ(selected.id, -1)}
</button> onDeselect={() => setSelectedId(null)}
</div>
) : (
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
Using the default hero image.
</p>
)}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', marginTop: 10, cursor: 'pointer' }}>
{uploading ? 'Uploading…' : 'Upload image'}
<input type="file" accept="image/*" onChange={onUploadBg} hidden disabled={uploading} />
</label>
</div>
<div>
<span className="field-label">Background position</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, maxWidth: 132 }}>
{POS_Y.map((py) =>
POS_X.map((px) => {
const activePos = (bg.position_x || 'left') === px && (bg.position_y || 'center') === py
return (
<button
key={`${px}-${py}`}
title={`${py} ${px}`}
onClick={() => patchBg({ position_x: px, position_y: py })}
style={{
height: 36,
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${activePos ? 'var(--accent)' : 'var(--line)'}`,
background: activePos ? 'var(--blue)' : 'transparent',
}}
/>
)
}),
)}
</div>
</div>
<div>
<span className="field-label">Overlay darkness {Math.round(overlay * 100)}%</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={overlay}
onChange={(e) => setOpacity(Number(e.target.value))}
style={{ width: '100%' }}
/> />
</div> ) : (
<BackgroundPanel bg={bg} overlay={overlay} uploading={uploading} onUpload={onUploadBg} patchBg={patchBg} setOpacity={setOpacity} />
)}
</aside> </aside>
</div> </div>
</section> </section>
) )
} }
// ── Background / overlay panel (no element selected) ────────────────────
function BackgroundPanel({ bg, overlay, uploading, onUpload, patchBg, setOpacity }) {
return (
<>
<p className="field-label" style={{ margin: 0 }}>Background &amp; overlay</p>
<div>
<span className="field-label">Background image</span>
{bg.image_url ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src={bg.image_url} alt="" style={{ width: 70, height: 44, objectFit: 'cover', borderRadius: 6, border: '1px solid var(--line)' }} />
<button onClick={() => patchBg({ image_url: null })} className="pill" style={{ fontSize: '0.8rem' }}>Clear</button>
</div>
) : (
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default hero image.</p>
)}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', marginTop: 10, cursor: 'pointer' }}>
{uploading ? 'Uploading…' : 'Upload image'}
<input type="file" accept="image/*" onChange={onUpload} hidden disabled={uploading} />
</label>
</div>
<div>
<span className="field-label">Background position</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, maxWidth: 132 }}>
{POS_Y.map((py) =>
POS_X.map((px) => {
const activePos = (bg.position_x || 'left') === px && (bg.position_y || 'center') === py
return (
<button
key={`${px}-${py}`}
title={`${py} ${px}`}
onClick={() => patchBg({ position_x: px, position_y: py })}
style={{ height: 36, borderRadius: 6, cursor: 'pointer', border: `1px solid ${activePos ? 'var(--accent)' : 'var(--line)'}`, background: activePos ? 'var(--blue)' : 'transparent' }}
/>
)
}),
)}
</div>
</div>
<div>
<span className="field-label">Overlay darkness {Math.round(overlay * 100)}%</span>
<input type="range" min="0" max="1" step="0.01" value={overlay} onChange={(e) => setOpacity(Number(e.target.value))} style={{ width: '100%' }} />
</div>
</>
)
}
// ── Per-element properties ──────────────────────────────────────────────
function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselect }) {
return (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<p className="field-label" style={{ margin: 0 }}>{element.type.replace('_', ' ')}</p>
<span className="link-accent" style={{ fontSize: '0.8rem' }} onClick={onDeselect}>Done</span>
</div>
{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>
)}
<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={onForward} className="pill" style={{ fontSize: '0.8rem' }}>Bring forward</button>
<button onClick={onRemove} className="pill" style={{ marginLeft: 'auto', fontSize: '0.8rem', color: '#d98b84', borderColor: '#6e3b38' }}>Delete</button>
</div>
</>
)
}
const ALIGNS = ['left', 'center', 'right']
function AlignField({ value, onChange }) {
return (
<label>
<span className="field-label">Align</span>
<select className="input" value={value || 'center'} onChange={(e) => onChange(e.target.value)}>
{ALIGNS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</label>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
{lines.map((line, i) => (
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input className="input" value={line.text} onChange={(e) => setLine(i, { text: e.target.value })} placeholder="Text" />
<div style={{ display: 'flex', gap: 8 }}>
<select className="input" value={line.tag || 'p'} onChange={(e) => setLine(i, { tag: e.target.value })} style={{ flex: 1 }}>
{['h1', 'h2', 'h3', 'p', 'span'].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<input
className="input"
type="number"
value={typeof line.fontSize === 'number' ? line.fontSize : ''}
onChange={(e) => { const n = parseInt(e.target.value, 10); setLine(i, { fontSize: Number.isFinite(n) ? n : undefined }) }}
placeholder="px"
style={{ width: 70 }}
/>
<input type="color" value={hexOf(line.color)} onChange={(e) => 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" />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="checkbox" checked={(line.weight || 400) >= 700} onChange={(e) => setLine(i, { weight: e.target.checked ? 700 : 400 })} />
<span className="sans" style={{ fontSize: '0.82rem', color: 'var(--muted)' }}>Bold</span>
</label>
{lines.length > 1 && (
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeLine(i)}>Remove line</span>
)}
</div>
</div>
))}
<button onClick={addLine} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add line</button>
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
{items.map((it, i) => (
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input className="input" value={it.label} onChange={(e) => setItem(i, { label: e.target.value })} placeholder="Label" />
<input className="input" value={it.to} onChange={(e) => setItem(i, { to: e.target.value })} placeholder="/path" style={{ fontFamily: 'ui-monospace,Menlo,monospace' }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<select className="input" value={it.variant || 'primary'} onChange={(e) => setItem(i, { variant: e.target.value })} style={{ flex: 1 }}>
<option value="primary">primary</option>
<option value="ghost">ghost</option>
</select>
{items.length > 1 && (
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeItem(i)}>Remove</span>
)}
</div>
</div>
))}
<button onClick={addItem} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add button</button>
</div>
)
}

View File

@@ -527,6 +527,25 @@ button[disabled] {
text-decoration: line-through; 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 ===== */ /* ===== Admin tables ===== */
.adm-table { .adm-table {
width: 100%; width: 100%;