Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
658 lines
29 KiB
JavaScript
658 lines
29 KiB
JavaScript
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 <input> 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 <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 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 (
|
|
<section>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
|
<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>
|
|
|
|
{/* 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>
|
|
<button onClick={() => addElement('moon')} className="pill">+ Moon</button>
|
|
<button onClick={() => addElement('badge')} className="pill">+ Badge</button>
|
|
<button onClick={() => addElement('image')} className="pill">+ Image</button>
|
|
<button
|
|
onClick={() => setSnap((v) => !v)}
|
|
className="pill"
|
|
style={{ marginLeft: 'auto', borderColor: snap ? 'var(--accent)' : 'var(--line)', color: snap ? 'var(--accent)' : 'var(--muted)' }}
|
|
>
|
|
Snap grid: {snap ? 'on' : 'off'}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
<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: '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: '16px 16px', pointerEvents: 'none', zIndex: 0 }} />
|
|
)}
|
|
{elements.map((el) => (
|
|
<HeroElement
|
|
key={el.id}
|
|
element={el}
|
|
editor
|
|
selected={el.id === selectedId}
|
|
onPointerDown={(e) => onElPointerDown(e, el)}
|
|
>
|
|
{el.id === selectedId && RESIZABLE[el.type] && (
|
|
<div
|
|
onPointerDown={(e) => 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' }}
|
|
/>
|
|
)}
|
|
</HeroElement>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
|
Click to select · drag to move · drag the corner handle to resize (text scales with the box) · Delete key removes the selected element.
|
|
</p>
|
|
</div>
|
|
|
|
<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}
|
|
element={selected}
|
|
onProps={(patch) => updateProps(selected.id, patch)}
|
|
onRemove={() => removeElement(selected.id)}
|
|
onForward={() => bumpZ(selected.id, 1)}
|
|
onBack={() => bumpZ(selected.id, -1)}
|
|
onDeselect={() => setSelectedId(null)}
|
|
/>
|
|
) : (
|
|
<BackgroundPanel bg={bg} overlay={overlay} uploading={uploading} onUpload={onUploadBg} patchBg={patchBg} setOpacity={setOpacity} />
|
|
)}
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Background / overlay panel (no element selected) ────────────────────
|
|
function BackgroundPanel({ bg, overlay, uploading, onUpload, patchBg, setOpacity }) {
|
|
return (
|
|
<>
|
|
<p className="field-label" style={{ margin: 0 }}>Background & 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} />}
|
|
{element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
|
|
{element.type === 'badge' && <BadgePanel element={element} onProps={onProps} />}
|
|
{element.type === 'image' && <ImagePanel element={element} onProps={onProps} />}
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div>
|
|
<span className="field-label">Moon image</span>
|
|
{p.src ? (
|
|
<img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />
|
|
) : (
|
|
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p>
|
|
)}
|
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
|
{uploadLabel(up, !!p.src)}
|
|
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
|
</label>
|
|
{p.src && (
|
|
<span className="link-accent" style={{ fontSize: '0.8rem', marginLeft: 10 }} onClick={() => onProps({ src: '' })}>Use default</span>
|
|
)}
|
|
</div>
|
|
<label>
|
|
<span className="field-label">Alt text</span>
|
|
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
|
</label>
|
|
<label>
|
|
<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.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>
|
|
)
|
|
}
|
|
|
|
function BadgePanel({ element, onProps }) {
|
|
const p = element.props || {}
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<label>
|
|
<span className="field-label">Text</span>
|
|
<input className="input" value={p.text || ''} onChange={(e) => onProps({ text: e.target.value })} />
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 12 }}>
|
|
<label style={{ flex: 1 }}>
|
|
<span className="field-label">Background</span>
|
|
<input type="color" value={hexOf(p.bgColor)} onChange={(e) => onProps({ bgColor: e.target.value })} style={swatch} />
|
|
</label>
|
|
<label style={{ flex: 1 }}>
|
|
<span className="field-label">Text color</span>
|
|
<input type="color" value={hexOf(p.textColor)} onChange={(e) => onProps({ textColor: e.target.value })} style={swatch} />
|
|
</label>
|
|
</div>
|
|
<label>
|
|
<span className="field-label">Corner radius — {Math.min(p.borderRadius ?? 999, 24)}px</span>
|
|
<input type="range" min="0" max="24" step="1" value={Math.min(p.borderRadius ?? 999, 24)} onChange={(e) => onProps({ borderRadius: Number(e.target.value) })} style={{ width: '100%' }} />
|
|
</label>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ImagePanel({ element, onProps }) {
|
|
const p = element.props || {}
|
|
const { up, onFile } = useImageUpload(onProps)
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div>
|
|
<span className="field-label">Image</span>
|
|
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
|
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
|
{uploadLabel(up, !!p.src)}
|
|
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
|
</label>
|
|
</div>
|
|
<label>
|
|
<span className="field-label">Width — {p.width || 40}%</span>
|
|
<input type="range" min="10" max="100" step="1" value={p.width || 40} onChange={(e) => onProps({ width: Number(e.target.value) })} style={{ width: '100%' }} />
|
|
</label>
|
|
<label>
|
|
<span className="field-label">Alt text</span>
|
|
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
|
</label>
|
|
</div>
|
|
)
|
|
}
|