Files
website/client/src/routes/admin/views/PageBuilder.jsx
Claude 1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:36 -05:00

454 lines
17 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'
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}>
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'}
</button>
</div>
{error && (
<div className="pb-error sans">
{error}
{details.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{details.map((d, i) => <li key={i}>{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(/[^0-9]/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>
)
}