import { useEffect, useRef, useState } from 'react' import HeroElement from '../../../components/HeroElement.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { api } from '../../../api/client.js' import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLayout.js' 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') // Soft page-weight warning shown before uploading a large hero image. This is // only a nudge (hero images render on the public landing page); the server hard- // limits uploads at 8 MB. Returns true when the caller should abort the upload. const WARN_UPLOAD_MB = 5 function tooLargeToUpload(size) { return ( size > WARN_UPLOAD_MB * 1024 * 1024 && !confirm(`This image is over ${WARN_UPLOAD_MB} MB and may slow the page. Upload anyway?`) ) } // Label for an image-upload button: busy, replace-existing, or first upload. function uploadLabel(up, hasSrc) { if (up) return 'Uploading…' return hasSrc ? 'Replace' : 'Upload' } // Shared image-upload behaviour for the element panels that point props.src at // the uploaded URL (moon + image). Returns the busy flag and file handler. function useImageUpload(onProps) { const [up, setUp] = useState(false) async function onFile(e) { const f = e.target.files?.[0] e.target.value = '' if (!f) return if (tooLargeToUpload(f.size)) return setUp(true) try { const { url } = await api.admin.upload(f) onProps({ src: url }) } catch { /* ignore */ } finally { setUp(false) } } return { up, onFile } } 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' }] } } } if (type === 'moon') return { ...base, props: { size: 96, glow: 0.5 } } 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' } // Scale a text line's font size by a ratio when its box is resized, so the corner // handle acts as a WYSIWYG zoom that keeps the h1/h2/p ratios intact. Numeric px // sizes (editor-authored) and simple rem/em/px strings scale; responsive strings // like clamp()/vw are left alone so they keep adapting to the viewport. const FONT_UNIT_RE = /^(\d*\.?\d+)(rem|em|px)$/ function scaleFontSize(v, ratio) { if (typeof v === 'number') return Math.max(6, Math.round(v * ratio)) if (typeof v === 'string') { const m = FONT_UNIT_RE.exec(v.trim()) if (m) return `${round2(parseFloat(m[1]) * ratio)}${m[2]}` } return v } // The props patch for a resize drag, per element type: image width is a % of the // canvas, moon size is px, and a text_block resizes its box and scales every // line's font proportionally. `ctx` carries the drag origin + measured geometry. function resizePatch(el, ctx) { const { orig, dxPx, dxLogical, rectWidth, baseWidth, baseLines } = ctx if (el.type === 'image') { return { width: Math.round(clamp(orig + (dxPx / rectWidth) * 100, 5, 100)) } // % } if (el.type === 'moon') { return { size: Math.round(clamp(orig + dxLogical, 24, 400)) } // px } const width = Math.round(clamp(orig + dxLogical, 120, 1180)) const ratio = baseWidth ? width / baseWidth : 1 const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) })) return { width, lines } } export default function HeroEditor() { 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 [snap, setSnap] = useState(false) const [scale, setScale] = useState(1) const teaserRef = useRef('') const skipSave = useRef(true) const canvasRef = useRef(null) // the 1280x720 stage (scaled to fit) const colRef = useRef(null) // measures available width // Render the canvas as a scaled 1280x720 stage so it's a faithful miniature of // the live hero (viewport-unit fonts + % positions all scale together). useEffect(() => { const el = colRef.current if (!el) return const recompute = () => setScale(Math.min(el.clientWidth / 1280, (window.innerHeight * 0.66) / 720)) recompute() const ro = new ResizeObserver(recompute) ro.observe(el) window.addEventListener('resize', recompute) return () => { ro.disconnect() window.removeEventListener('resize', recompute) } }, [loading]) useEffect(() => { let active = true api.admin .getSettings() .then((s) => { if (!active) return teaserRef.current = s.homepage_teaser || '' const liveL = parseLayout(s.hero_layout) setLive(liveL) skipSave.current = true setLayout(parseLayout(s.hero_layout_draft) || liveL || defaultLayout(teaserRef.current)) }) .catch(() => active && setError('Could not load hero settings.')) .finally(() => active && setLoading(false)) return () => { active = false } }, []) useEffect(() => { if (!layout) return if (skipSave.current) { skipSave.current = false return } setStatus('Saving…') const t = setTimeout(() => { api.admin .updateSettings({ hero_layout_draft: JSON.stringify(layout) }) .then(() => setStatus('Draft saved')) .catch(() => setStatus('Save failed')) }, 800) 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 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 stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as % const stepY = (8 / 720) * 100 const move = (ev) => { 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 = () => { node.removeEventListener('pointermove', move) node.removeEventListener('pointerup', up) } node.addEventListener('pointermove', move) 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 let defaultDim = 64 if (dim === 'width') defaultDim = el.type === 'image' ? 40 : 600 const orig = el.props?.[dim] ?? defaultDim // Snapshot the starting width + lines for text blocks so font scaling is always // computed against the drag origin (no rounding drift as the pointer moves). const baseWidth = el.type === 'text_block' ? orig : 0 const baseLines = el.type === 'text_block' ? el.props?.lines || [] : null const node = e.currentTarget try { node.setPointerCapture(e.pointerId) } catch { /* ignore */ } const move = (ev) => { const dxPx = ev.clientX - sx const dxLogical = dxPx / scale // client px → stage px updateProps(el.id, resizePatch(el, { orig, dxPx, dxLogical, rectWidth: rect.width, baseWidth, baseLines })) } 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 = '' if (!file) return if (tooLargeToUpload(file.size)) return setUploading(true) try { const { url } = await api.admin.upload(file) patchBg({ image_url: url }) } catch (err) { setStatus(err.message || 'Upload failed') } finally { setUploading(false) } } async function preview() { 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 { await api.admin.updateSettings({ hero_layout: json, hero_layout_draft: json }) setLive(layout) setStatus('Published ✓') } catch (err) { setStatus(err.message || 'Publish failed') } } async function revert() { if (!confirm('Discard draft changes and revert to the live hero?')) return skipSave.current = true setSelectedId(null) setLayout(live || defaultLayout(teaserRef.current)) await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {}) setStatus('Reverted to live') } return (

Compose the portal hero. {status && · {status}}

{/* Element tray */}
Add:
{ if (e.target === e.currentTarget) setSelectedId(null) }} style={{ position: 'absolute', top: 0, left: 0, width: 1280, height: 720, transformOrigin: 'top left', transform: `scale(${scale})`, ...heroBackground(layout), }} > {snap && (
)} {elements.map((el) => ( onElPointerDown(e, el)} > {el.id === selectedId && RESIZABLE[el.type] && (
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' }} /> )} ))}

Click to select · drag to move · drag the corner handle to resize (text scales with the box) · Delete key removes the selected element.

) } // ── 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' && } {element.type === 'moon' && } {element.type === 'badge' && } {element.type === 'image' && }
) } 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 )}
))}
) } 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 || {} const { up, onFile } = useImageUpload(onProps) return (
Moon image {p.src ? ( ) : (

Using the default moon from the hero artwork.

)} {p.src && ( onProps({ src: '' })}>Use default )}
) } function BadgePanel({ element, onProps }) { const p = element.props || {} return (
) } function ImagePanel({ element, onProps }) { const p = element.props || {} const { up, onFile } = useImageUpload(onProps) return (
Image {p.src && }
) }