Hero editor: larger faithful canvas + real moon from the hero art
Addresses feedback that the editor was too small/cramped (elements overlapping) and that the generated CSS moon looked bad. - AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap) - HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the column, capped at ~66vh). Because viewport-unit fonts and % positions scale together, the canvas is now a faithful miniature of the live hero — the default text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid; resize math is scale-aware. - Moon element now renders the actual moon cropped from the hero artwork (client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS dot; MoonPanel exposes size + glow. Verified: at 1440px the canvas is ~785x442 with the panel beside it, default elements don't overlap, and the moon loads the real image. Build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BIN
client/public/assets/img/hero-moon.png
Normal file
BIN
client/public/assets/img/hero-moon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
|
||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||
|
||||
// Font family tokens a line may opt into; default is the page serif.
|
||||
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
||||
@@ -105,8 +106,23 @@ function content(element) {
|
||||
return <TextBlock props={element.props || {}} />
|
||||
case 'buttons':
|
||||
return <Buttons props={element.props || {}} />
|
||||
case 'moon':
|
||||
return <MoonDot size={element.props?.size || 48} glow={element.props?.glow ?? 0.45} color={element.props?.color} />
|
||||
case 'moon': {
|
||||
const size = element.props?.size || 96
|
||||
const glow = element.props?.glow ?? 0.45
|
||||
return (
|
||||
<img
|
||||
src={MOON_IMAGE}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: size,
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
filter: glow ? `drop-shadow(0 0 ${size * 0.45}px rgba(216,226,239,${glow}))` : undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'badge':
|
||||
return <Badge props={element.props || {}} />
|
||||
case 'image':
|
||||
|
||||
@@ -41,6 +41,8 @@ export default function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || 'Admin'
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
@@ -147,7 +149,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 1000, width: '100%' }}>
|
||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 === '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
|
||||
@@ -37,9 +37,27 @@ export default function HeroEditor() {
|
||||
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)
|
||||
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
|
||||
@@ -133,8 +151,8 @@ export default function HeroEditor() {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas
|
||||
const stepY = (8 / rect.height) * 100
|
||||
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)
|
||||
@@ -169,10 +187,11 @@ export default function HeroEditor() {
|
||||
}
|
||||
const move = (ev) => {
|
||||
const dxPx = ev.clientX - sx
|
||||
const dxLogical = dxPx / scale // client px → stage px
|
||||
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
|
||||
else if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
|
||||
else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
|
||||
updateProps(el.id, { [dim]: Math.round(val) })
|
||||
}
|
||||
const up = () => {
|
||||
@@ -253,24 +272,26 @@ export default function HeroEditor() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 460px', minWidth: 320 }}>
|
||||
<div ref={colRef} style={{ flex: '1 1 620px', minWidth: 320 }}>
|
||||
<div style={{ position: 'relative', width: 1280 * scale, height: 720 * scale, maxWidth: '100%', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--line)', background: 'var(--bg-deep)' }}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) setSelectedId(null)
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--line)',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
transformOrigin: 'top left',
|
||||
transform: `scale(${scale})`,
|
||||
...heroBackground(layout),
|
||||
}}
|
||||
>
|
||||
{snap && (
|
||||
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '8px 8px', pointerEvents: 'none', zIndex: 0 }} />
|
||||
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '16px 16px', pointerEvents: 'none', zIndex: 0 }} />
|
||||
)}
|
||||
{elements.map((el) => (
|
||||
<HeroElement
|
||||
@@ -290,12 +311,13 @@ export default function HeroEditor() {
|
||||
</HeroElement>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||
Click to select · drag to move · Delete key removes the selected element.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 320px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 20 }}>
|
||||
{selected ? (
|
||||
<ElementPanel
|
||||
key={selected.id}
|
||||
@@ -479,17 +501,14 @@ function MoonPanel({ element, onProps }) {
|
||||
const p = element.props || {}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Uses the moon from the hero artwork.</p>
|
||||
<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%' }} />
|
||||
<span className="field-label">Size — {p.size || 96}px</span>
|
||||
<input type="range" min="24" max="320" step="1" value={p.size || 96} 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 }} />
|
||||
<span className="field-label">Glow — {Math.round((p.glow ?? 0.5) * 100)}%</span>
|
||||
<input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.5} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user