Files
website/client/src/routes/admin/views/PageBuilder.jsx
wtclaude 12d50fd615
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
chore(quality): resolve SonarQube code smells across website
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>
2026-07-21 04:35:39 -05:00

457 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import Modal from '../../../components/Modal.jsx'
import { Loading } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import '../../../blocks/index.js' // registers all block types
import { listBlocks, getBlock, makeBlockId } from '../../../blocks/registry.js'
import { SelectField, TextField, TextAreaField } from '../../../blocks/editorKit.jsx'
const LAYOUTS = [
['default', 'Default'],
['full_width', 'Full width'],
['landing', 'Landing'],
]
const NAV_GROUPS = [
['', 'None'],
['main', 'Main nav'],
['footer', 'Footer'],
['account', 'Account'],
['hidden', 'Hidden'],
]
const EMPTY = {
title: '',
slug: '',
status: 'draft',
blocks: [],
metadata: { seoTitle: '', metaDescription: '', ogImage: '', canonicalUrl: '', robots: '' },
settings: { layout: 'default', showInNav: false, navGroup: '', navOrder: null, protected: false },
}
// Map an API page (grouped shape) into local editable form state.
function toForm(page) {
return {
title: page.title || '',
slug: page.slug || '',
status: page.status || 'draft',
blocks: Array.isArray(page.blocks) ? page.blocks : [],
metadata: { ...EMPTY.metadata, ...cleanNulls(page.metadata) },
settings: {
layout: page.settings?.layout || 'default',
showInNav: Boolean(page.settings?.showInNav),
navGroup: page.settings?.navGroup || '',
navOrder: page.settings?.navOrder ?? null,
protected: Boolean(page.settings?.protected),
},
}
}
function cleanNulls(obj) {
const out = {}
for (const [k, v] of Object.entries(obj || {})) out[k] = v == null ? '' : v
return out
}
export default function PageBuilder() {
const { id } = useParams()
const isEdit = Boolean(id)
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY)
const [protectedNow, setProtectedNow] = useState(false) // server truth, edit mode
const [loading, setLoading] = useState(isEdit)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [details, setDetails] = useState([]) // block validation errors
const [notice, setNotice] = useState('')
const [tab, setTab] = useState('content')
const [pwModal, setPwModal] = useState(false)
const [dragIndex, setDragIndex] = useState(null)
const palette = useMemo(() => listBlocks(), [])
useEffect(() => {
if (!isEdit) return
let active = true
setLoading(true)
api.admin
.getPage(id)
.then((page) => {
if (!active) return
setForm(toForm(page))
setProtectedNow(Boolean(page.settings?.protected))
setLoading(false)
})
.catch((err) => {
if (!active) return
setError(err.message || 'Could not load the page.')
setLoading(false)
})
return () => {
active = false
}
}, [id, isEdit])
// ── Block operations ────────────────────────────────────────────────
const addBlock = useCallback((type) => {
const def = getBlock(type)
if (!def) return
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
setForm((f) => ({ ...f, blocks: [...f.blocks, block] }))
}, [])
const updateBlock = useCallback((blockId, nextProps) => {
setForm((f) => ({
...f,
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, props: nextProps } : b)),
}))
}, [])
const toggleVisible = useCallback((blockId) => {
setForm((f) => ({
...f,
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, visible: b.visible === false } : b)),
}))
}, [])
const removeBlock = useCallback((blockId) => {
setForm((f) => ({ ...f, blocks: f.blocks.filter((b) => b.id !== blockId) }))
}, [])
const moveBlock = useCallback((from, to) => {
setForm((f) => {
if (to < 0 || to >= f.blocks.length) return f
const next = [...f.blocks]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return { ...f, blocks: next }
})
}, [])
function onDrop(index) {
if (dragIndex === null || dragIndex === index) return setDragIndex(null)
moveBlock(dragIndex, index)
setDragIndex(null)
}
// ── Form field setters ──────────────────────────────────────────────
const setField = (k) => (v) => setForm((f) => ({ ...f, [k]: v }))
const setMeta = (k) => (v) => setForm((f) => ({ ...f, metadata: { ...f.metadata, [k]: v } }))
const setSetting = (k) => (v) => setForm((f) => ({ ...f, settings: { ...f.settings, [k]: v } }))
// Serialize local state into an API payload. Empty metadata strings become
// null; navGroup '' becomes null.
function payload() {
const metadata = {}
for (const [k, v] of Object.entries(form.metadata)) metadata[k] = v === '' ? null : v
const settings = {
layout: form.settings.layout,
showInNav: Boolean(form.settings.showInNav),
navGroup: form.settings.navGroup === '' ? null : form.settings.navGroup,
navOrder: form.settings.navOrder === '' || form.settings.navOrder == null ? null : Number(form.settings.navOrder),
}
return { title: form.title.trim(), status: form.status, blocks: form.blocks, metadata, settings }
}
async function save({ silent } = {}) {
setBusy(true)
setError('')
setDetails([])
setNotice('')
try {
if (isEdit) {
await api.admin.updatePage(id, payload())
if (!silent) setNotice('Saved.')
} else {
if (!form.slug.trim()) throw new Error('A slug is required.')
const created = await api.admin.createPage({ slug: form.slug.trim(), ...payload() })
navigate(`/admin/pages/${created.id}`, { replace: true })
}
} catch (err) {
setError(err.message || 'Could not save the page.')
if (err.body?.details) setDetails(err.body.details)
} finally {
setBusy(false)
}
}
async function togglePublish() {
const next = form.status === 'published' ? 'draft' : 'published'
setForm((f) => ({ ...f, status: next }))
// Persist immediately (edit mode) so the status change isn't lost.
if (isEdit) {
setBusy(true)
setError('')
try {
await api.admin.updatePage(id, { ...payload(), status: next })
setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
} catch (err) {
setError(err.message || 'Could not change status.')
} finally {
setBusy(false)
}
}
}
async function protectPage() {
setBusy(true)
setError('')
try {
await api.admin.updatePage(id, { settings: { protected: true } })
setProtectedNow(true)
setForm((f) => ({ ...f, settings: { ...f.settings, protected: true } }))
setNotice('Page protected.')
} catch (err) {
setError(err.message || 'Could not protect the page.')
} finally {
setBusy(false)
}
}
async function unprotectPage(password) {
setBusy(true)
setError('')
try {
await api.admin.unprotectPage(id, password)
setProtectedNow(false)
setForm((f) => ({ ...f, settings: { ...f.settings, protected: false } }))
setPwModal(false)
setNotice('Protection removed.')
} catch (err) {
setError(err.message || 'Could not unprotect the page.')
} finally {
setBusy(false)
}
}
async function preview() {
setError('')
try {
const { token } = await api.admin.createPagePreview(id)
window.open(`/preview/${id}/${token}`, '_blank', 'noopener')
} catch (err) {
setError(err.message || 'Could not create a preview link.')
}
}
async function remove() {
if (!confirm('Delete this page? This cannot be undone.')) return
setBusy(true)
setError('')
try {
await api.admin.deletePage(id)
navigate('/admin/pages')
} catch (err) {
setError(err.message || 'Could not delete the page.')
setBusy(false)
}
}
if (loading) return <Loading />
const published = form.status === 'published'
let saveLabel = isEdit ? 'Save' : 'Create'
if (busy) saveLabel = 'Saving…'
return (
<section>
{/* Toolbar */}
<div className="pb-toolbar">
<button className="pill" onClick={() => navigate('/admin/pages')}> Pages</button>
<span className={`badge ${published ? 'badge-pub' : 'badge-draft'}`}>{published ? 'Published' : 'Draft'}</span>
<div style={{ flex: 1 }} />
{isEdit && (
<button className="pill" onClick={preview} disabled={busy}>Preview</button>
)}
{isEdit && (
<button className="pill" onClick={togglePublish} disabled={busy}>
{published ? 'Unpublish' : 'Publish'}
</button>
)}
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
{saveLabel}
</button>
</div>
{error && (
<div className="pb-error sans">
{error}
{details.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{details.map((d) => <li key={d}>{d}</li>)}
</ul>
)}
</div>
)}
{notice && <div className="pb-notice sans">{notice}</div>}
{/* Title + slug */}
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', margin: '16px 0' }}>
<label style={{ flex: '2 1 320px' }}>
<span className="field-label">Title</span>
<input className="input" value={form.title} onChange={(e) => setField('title')(e.target.value)} />
</label>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Slug {isEdit && '(fixed)'}</span>
<input
className="input"
value={form.slug}
disabled={isEdit}
placeholder="my-page"
onChange={(e) => setField('slug')(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
/>
</label>
</div>
{/* Tabs */}
<div className="pb-tabs">
<button className={`pb-tab ${tab === 'content' ? 'is-active' : ''}`} onClick={() => setTab('content')}>Content</button>
<button className={`pb-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Settings &amp; SEO</button>
</div>
{tab === 'content' && (
<>
<div className="pb-palette">
<span className="field-label" style={{ margin: '0 6px 0 0' }}>Add block</span>
{palette.map((b) => (
<button key={b.type} className="pill" onClick={() => addBlock(b.type)} disabled={busy}>
<span aria-hidden style={{ marginRight: 6 }}>{b.icon}</span>{b.label}
</button>
))}
</div>
<div className="pb-canvas">
{form.blocks.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', padding: 30 }}>
No blocks yet add one from the palette above.
</p>
)}
{form.blocks.map((block, i) => {
const def = getBlock(block.type)
const Editor = def?.editor
const hidden = block.visible === false
return (
<div
key={block.id}
className={`pb-block-card ${hidden ? 'is-hidden' : ''} ${dragIndex === i ? 'is-dragging' : ''}`}
draggable
onDragStart={() => setDragIndex(i)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(i)}
onDragEnd={() => setDragIndex(null)}
>
<div className="pb-block-head">
<span className="pb-drag" title="Drag to reorder"></span>
<strong className="sans">{def?.label || block.type}</strong>
<div style={{ flex: 1 }} />
<button className="pill pb-mini" title={hidden ? 'Show' : 'Hide'} onClick={() => toggleVisible(block.id)}>
{hidden ? '🙈' : '👁'}
</button>
<button className="pill pb-mini" disabled={i === 0} onClick={() => moveBlock(i, i - 1)} title="Move up"></button>
<button className="pill pb-mini" disabled={i === form.blocks.length - 1} onClick={() => moveBlock(i, i + 1)} title="Move down"></button>
<button className="pill pb-mini" onClick={() => removeBlock(block.id)} title="Remove"></button>
</div>
<div className="pb-block-body">
{Editor ? (
<Editor props={block.props || {}} onChange={(p) => updateBlock(block.id, p)} />
) : (
<p className="sans dim">Unknown block type: {block.type}</p>
)}
</div>
</div>
)
})}
</div>
</>
)}
{tab === 'settings' && (
<div className="pb-settings">
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">SEO &amp; metadata</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
<TextField label="SEO title" value={form.metadata.seoTitle} maxLength={200} onChange={setMeta('seoTitle')} hint="Overrides the page title in the browser tab / search results." />
<TextAreaField label="Meta description" value={form.metadata.metaDescription} rows={2} maxLength={400} onChange={setMeta('metaDescription')} />
<TextField label="OG image URL" value={form.metadata.ogImage} maxLength={500} onChange={setMeta('ogImage')} />
<TextField label="Canonical URL" value={form.metadata.canonicalUrl} maxLength={500} onChange={setMeta('canonicalUrl')} />
<TextField label="Robots" value={form.metadata.robots} maxLength={100} onChange={setMeta('robots')} placeholder="index,follow" />
</div>
</div>
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">Layout &amp; navigation</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
<SelectField label="Layout" value={form.settings.layout} onChange={setSetting('layout')} options={LAYOUTS} />
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={form.settings.showInNav} onChange={(e) => setSetting('showInNav')(e.target.checked)} />
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
</label>
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/\D/g, ''))} hint="Lower numbers appear first." />
</div>
</div>
<div className="card" style={{ padding: 18 }}>
<p className="card-kicker">Protection &amp; danger zone</p>
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 8 }}>
A protected page cant be deleted and its protection can only be removed by re-entering your password.
</p>
{!isEdit && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Save the page first to manage protection.</p>}
{isEdit && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 10 }}>
{protectedNow ? (
<button className="pill" onClick={() => setPwModal(true)} disabled={busy}>🔓 Remove protection</button>
) : (
<button className="pill" onClick={protectPage} disabled={busy}>🔒 Protect page</button>
)}
<button className="pill pb-danger" onClick={remove} disabled={busy || protectedNow} title={protectedNow ? 'Unprotect first' : 'Delete'}>
Delete page
</button>
</div>
)}
</div>
</div>
)}
{pwModal && (
<UnprotectModal onCancel={() => setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} />
)}
</section>
)
}
function UnprotectModal({ onCancel, onConfirm, busy, error }) {
const [pw, setPw] = useState('')
return (
<Modal
title="Confirm your password"
onClose={onCancel}
width={420}
footer={
<>
<button className="pill" onClick={onCancel} disabled={busy}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={() => onConfirm(pw)} disabled={busy || !pw}>
{busy ? 'Verifying…' : 'Remove protection'}
</button>
</>
}
>
<p className="sans dim" style={{ marginTop: 0, fontSize: '0.88rem' }}>
Removing protection is a sensitive change re-enter your account password to continue.
</p>
<input
type="password"
className="input"
autoFocus
value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)}
placeholder="Password"
/>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.85rem', marginBottom: 0 }}>{error}</p>}
</Modal>
)
}