Hero Phase 2: editor shell + background/overlay + draft/preview/publish

Second phase of the hero canvas editor (see HERO_EDITOR.md).

- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
  parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
  - live canvas preview (16:9) rendering the draft via HeroElement
  - background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
    grid, overlay opacity slider — all update the canvas in real time
  - debounced (800ms) auto-save to hero_layout_draft
  - Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
  "showing unpublished draft" banner; normal load renders the published layout

No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 02:32:09 -05:00
parent 578bffc51f
commit ccad727ec3
6 changed files with 355 additions and 89 deletions

View File

@@ -0,0 +1,216 @@
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']
export default function HeroEditor() {
const [layout, setLayout] = useState(null) // working draft
const [live, setLive] = useState(null) // last published (for revert)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [status, setStatus] = useState('')
const [uploading, setUploading] = useState(false)
const teaserRef = useRef('')
const skipSave = useRef(true) // don't autosave right after load / revert
// Load draft → live → default.
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
}
}, [])
// Debounced auto-save to the draft key.
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])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
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 patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } }))
async function onUploadBg(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
if (file.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) 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() {
// Force-save the draft so the new tab shows the latest.
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
const base = live || defaultLayout(teaserRef.current)
skipSave.current = true
setLayout(base)
await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
setStatus('Reverted to live')
}
return (
<section>
{/* Toolbar */}
<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' }}>
Compose the portal hero. {status && <span style={{ color: 'var(--accent)' }}>· {status}</span>}
</p>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={revert} className="pill">
Revert to live
</button>
<button onClick={preview} className="pill">
Preview
</button>
<button onClick={publish} className="btn btn-primary btn-sq">
Publish
</button>
</div>
</div>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* Canvas */}
<div style={{ flex: '1 1 460px', minWidth: 320 }}>
<div
style={{
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
borderRadius: 10,
overflow: 'hidden',
border: '1px solid var(--line)',
...heroBackground(layout),
}}
>
{elements.map((el) => (
<HeroElement key={el.id} element={el} />
))}
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
Live preview of the draft. Element drag &amp; properties arrive in the next phase.
</p>
</div>
{/* Background / overlay panel */}
<aside className="panel-flat" style={{ flex: '0 0 290px', padding: 18, display: 'flex', flexDirection: 'column', gap: 18 }}>
<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={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>
</aside>
</div>
</section>
)
}