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 const published = form.status === 'published' return (
{/* Toolbar */}
{published ? 'Published' : 'Draft'}
{isEdit && ( )} {isEdit && ( )}
{error && (
{error} {details.length > 0 && (
    {details.map((d, i) =>
  • {d}
  • )}
)}
)} {notice &&
{notice}
} {/* Title + slug */}
{/* Tabs */}
{tab === 'content' && ( <>
Add block {palette.map((b) => ( ))}
{form.blocks.length === 0 && (

No blocks yet — add one from the palette above.

)} {form.blocks.map((block, i) => { const def = getBlock(block.type) const Editor = def?.editor const hidden = block.visible === false return (
setDragIndex(i)} onDragOver={(e) => e.preventDefault()} onDrop={() => onDrop(i)} onDragEnd={() => setDragIndex(null)} >
{def?.label || block.type}
{Editor ? ( updateBlock(block.id, p)} /> ) : (

Unknown block type: {block.type}

)}
) })}
)} {tab === 'settings' && (

SEO & metadata

Layout & navigation

setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." />

Protection & danger zone

A protected page can’t be deleted and its protection can only be removed by re-entering your password.

{!isEdit &&

Save the page first to manage protection.

} {isEdit && (
{protectedNow ? ( ) : ( )}
)}
)} {pwModal && ( setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} /> )}
) } function UnprotectModal({ onCancel, onConfirm, busy, error }) { const [pw, setPw] = useState('') return ( } >

Removing protection is a sensitive change — re-enter your account password to continue.

setPw(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)} placeholder="Password" /> {error &&

{error}

}
) }