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 { 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.
|
// Font family tokens a line may opt into; default is the page serif.
|
||||||
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
||||||
@@ -105,8 +106,23 @@ function content(element) {
|
|||||||
return <TextBlock props={element.props || {}} />
|
return <TextBlock props={element.props || {}} />
|
||||||
case 'buttons':
|
case 'buttons':
|
||||||
return <Buttons props={element.props || {}} />
|
return <Buttons props={element.props || {}} />
|
||||||
case 'moon':
|
case 'moon': {
|
||||||
return <MoonDot size={element.props?.size || 48} glow={element.props?.glow ?? 0.45} color={element.props?.color} />
|
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':
|
case 'badge':
|
||||||
return <Badge props={element.props || {}} />
|
return <Badge props={element.props || {}} />
|
||||||
case 'image':
|
case 'image':
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export default function AdminLayout() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || 'Admin'
|
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)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
@@ -147,7 +149,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function newElement(type, z) {
|
|||||||
if (type === 'buttons') {
|
if (type === 'buttons') {
|
||||||
return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
|
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 === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } }
|
||||||
if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
|
if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
|
||||||
return base
|
return base
|
||||||
@@ -37,9 +37,27 @@ export default function HeroEditor() {
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [selectedId, setSelectedId] = useState(null)
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
const [snap, setSnap] = useState(false)
|
const [snap, setSnap] = useState(false)
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
const teaserRef = useRef('')
|
const teaserRef = useRef('')
|
||||||
const skipSave = useRef(true)
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -133,8 +151,8 @@ export default function HeroEditor() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas
|
const stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as %
|
||||||
const stepY = (8 / rect.height) * 100
|
const stepY = (8 / 720) * 100
|
||||||
const move = (ev) => {
|
const move = (ev) => {
|
||||||
let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 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)
|
let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
|
||||||
@@ -169,10 +187,11 @@ export default function HeroEditor() {
|
|||||||
}
|
}
|
||||||
const move = (ev) => {
|
const move = (ev) => {
|
||||||
const dxPx = ev.clientX - sx
|
const dxPx = ev.clientX - sx
|
||||||
|
const dxLogical = dxPx / scale // client px → stage px
|
||||||
let val
|
let val
|
||||||
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
|
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 if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
|
||||||
else val = clamp(orig + dxPx, 120, 1080) // text_block box px
|
else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
|
||||||
updateProps(el.id, { [dim]: Math.round(val) })
|
updateProps(el.id, { [dim]: Math.round(val) })
|
||||||
}
|
}
|
||||||
const up = () => {
|
const up = () => {
|
||||||
@@ -253,24 +272,26 @@ export default function HeroEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<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
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.target === e.currentTarget) setSelectedId(null)
|
if (e.target === e.currentTarget) setSelectedId(null)
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'absolute',
|
||||||
width: '100%',
|
top: 0,
|
||||||
aspectRatio: '16 / 9',
|
left: 0,
|
||||||
borderRadius: 10,
|
width: 1280,
|
||||||
overflow: 'hidden',
|
height: 720,
|
||||||
border: '1px solid var(--line)',
|
transformOrigin: 'top left',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
...heroBackground(layout),
|
...heroBackground(layout),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{snap && (
|
{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) => (
|
{elements.map((el) => (
|
||||||
<HeroElement
|
<HeroElement
|
||||||
@@ -290,12 +311,13 @@ export default function HeroEditor() {
|
|||||||
</HeroElement>
|
</HeroElement>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||||
Click to select · drag to move · Delete key removes the selected element.
|
Click to select · drag to move · Delete key removes the selected element.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{selected ? (
|
||||||
<ElementPanel
|
<ElementPanel
|
||||||
key={selected.id}
|
key={selected.id}
|
||||||
@@ -479,17 +501,14 @@ function MoonPanel({ element, onProps }) {
|
|||||||
const p = element.props || {}
|
const p = element.props || {}
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<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>
|
<label>
|
||||||
<span className="field-label">Size — {p.size || 64}px</span>
|
<span className="field-label">Size — {p.size || 96}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%' }} />
|
<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>
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">Glow — {Math.round((p.glow ?? 0.45) * 100)}%</span>
|
<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.45} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
|
<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>
|
|
||||||
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user