Add Wave 1 block renderers + editors (page builder step 3, client half)

Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:37:34 -05:00
parent 6d31869ba2
commit 764fb0c069
11 changed files with 678 additions and 8 deletions

View File

@@ -0,0 +1,26 @@
// Renders stored blocks via their registry component. Used by the public page
// route, the draft preview, and (recursively) the two_column block. Kept
// separate from the registry so both the renderer and the builder can import it.
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
// which registers every block before anything renders.
import { getBlock } from './registry.js'
/**
* Render one block. A block with `visible === false` renders nothing (admins
* hide blocks without deleting them). An unknown type also renders nothing —
* server validation prevents storing one, so this only guards a client/server
* registry skew rather than crashing the whole page.
*/
export default function BlockRenderer({ block }) {
if (!block || block.visible === false) return null
const def = getBlock(block.type)
if (!def || !def.component) return null
const Component = def.component
return <Component props={block.props || {}} block={block} />
}
/** Render an ordered array of blocks (array position = display order). */
export function BlockList({ blocks }) {
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
}

View File

@@ -0,0 +1,64 @@
// Shared form controls for block editors, styled with the existing admin design
// system (.field-label / .input / .select). Every block's editor is a
// ({ props, onChange }) component; these keep the seven of them consistent and
// short. onChange always receives the full next props object.
export function Field({ label, hint, children }) {
return (
<label style={{ display: 'block' }}>
<span className="field-label">{label}</span>
{children}
{hint && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
{hint}
</span>
)}
</label>
)
}
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
return (
<Field label={label} hint={hint}>
<input
type="text"
className="input"
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
)
}
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
return (
<Field label={label} hint={hint}>
<textarea
className="input"
rows={rows}
value={value ?? ''}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }}
/>
</Field>
)
}
// options: array of [value, label] tuples.
export function SelectField({ label, hint, value, onChange, options }) {
return (
<Field label={label} hint={hint}>
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
{options.map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
)
}

View File

@@ -9,11 +9,11 @@
export * from './registry'
// ── Wave 1 block definitions ──────────────────────────────────────────
// import './types/heading' // added in step 3
// import './types/richText'
// import './types/image'
// import './types/twoColumn'
// import './types/cta'
// import './types/divider'
// import './types/quote'
// ── Wave 1 block definitions (self-register on import) ─────────────────
import './types/heading.jsx'
import './types/richText.jsx'
import './types/image.jsx'
import './types/twoColumn.jsx'
import './types/cta.jsx'
import './types/divider.jsx'
import './types/quote.jsx'

View File

@@ -0,0 +1,63 @@
// cta block — a call-to-action button/link. Renders as an anchor styled with the
// existing button system (primary / secondary).
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const STYLES = [
['primary', 'Primary'],
['secondary', 'Secondary'],
]
function CtaBlock({ props }) {
if (!props.url || !props.text) return null
const style = props.style === 'secondary' ? 'secondary' : 'primary'
// External links get a safe rel; same-origin relative links don't need it.
const external = /^https?:\/\//i.test(props.url)
return (
<div className="page-cta-wrap">
<a
className={`btn btn-sq page-cta page-cta--${style}`}
href={props.url}
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
>
{props.text}
</a>
</div>
)
}
function CtaEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextField
label="Button text"
value={props.text}
maxLength={100}
onChange={(text) => onChange({ ...props, text })}
/>
<TextField
label="URL"
hint="A full https:// link or a same-site path like /wiki/getting-started."
value={props.url}
placeholder="https://…"
onChange={(url) => onChange({ ...props, url })}
/>
<SelectField
label="Style"
value={props.style || 'primary'}
onChange={(style) => onChange({ ...props, style })}
options={STYLES}
/>
</div>
)
}
registerBlock({
type: 'cta',
version: 1,
label: 'Button',
icon: '⇥',
component: CtaBlock,
editor: CtaEditor,
defaults: () => ({ text: '', url: '', style: 'primary' }),
})

View File

@@ -0,0 +1,25 @@
// divider block — a pure spacer / horizontal rule. No props, so its editor is
// just a note.
import { registerBlock } from '../registry'
function DividerBlock() {
return <hr className="page-divider" />
}
function DividerEditor() {
return (
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
A divider has no options it adds a horizontal rule and spacing.
</p>
)
}
registerBlock({
type: 'divider',
version: 1,
label: 'Divider',
icon: '—',
component: DividerBlock,
editor: DividerEditor,
defaults: () => ({}),
})

View File

@@ -0,0 +1,46 @@
// heading block — plain-text section heading (h1h4). Text is rendered as text
// (React escapes it); use rich_text for inline markup.
import { registerBlock } from '../registry'
import { SelectField, TextField } from '../editorKit.jsx'
const LEVELS = [
['h1', 'Heading 1'],
['h2', 'Heading 2'],
['h3', 'Heading 3'],
['h4', 'Heading 4'],
]
const VALID = ['h1', 'h2', 'h3', 'h4']
function HeadingBlock({ props }) {
const Tag = VALID.includes(props.level) ? props.level : 'h2'
return <Tag className="page-heading">{props.text}</Tag>
}
function HeadingEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SelectField
label="Level"
value={props.level || 'h2'}
onChange={(level) => onChange({ ...props, level })}
options={LEVELS}
/>
<TextField
label="Text"
value={props.text}
maxLength={200}
onChange={(text) => onChange({ ...props, text })}
/>
</div>
)
}
registerBlock({
type: 'heading',
version: 1,
label: 'Heading',
icon: 'H',
component: HeadingBlock,
editor: HeadingEditor,
defaults: () => ({ level: 'h2', text: '' }),
})

View File

@@ -0,0 +1,100 @@
// image block — a single image with optional caption and alignment. Upload
// reuses the shared admin uploader (returns { url }); the block stays URL-based
// until the Wave 3 asset picker lands.
import { useState } from 'react'
import { registerBlock } from '../registry'
import { api } from '../../api/client.js'
import { SelectField, TextField } from '../editorKit.jsx'
const ALIGN = [
['left', 'Left'],
['center', 'Center'],
['right', 'Right'],
['full', 'Full width'],
]
const VALID = ['left', 'center', 'right', 'full']
function ImageBlock({ props }) {
if (!props.src) return null
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
return (
<figure className={`page-image page-image--${align}`}>
<img src={props.src} alt={props.alt || ''} />
{props.caption && <figcaption>{props.caption}</figcaption>}
</figure>
)
}
function ImageEditor({ props, onChange }) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
async function onUpload(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploading(true)
setError('')
try {
const { url } = await api.admin.upload(file)
onChange({ ...props, src: url })
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setUploading(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<span className="field-label">Image</span>
<input
type="file"
accept="image/*"
onChange={onUpload}
className="sans"
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
/>
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading</span>}
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
{props.src && (
<img
src={props.src}
alt=""
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
/>
)}
</div>
<TextField
label="Alt text"
hint="Describes the image for screen readers and when it fails to load."
value={props.alt}
maxLength={300}
onChange={(alt) => onChange({ ...props, alt })}
/>
<TextField
label="Caption (optional)"
value={props.caption}
maxLength={500}
onChange={(caption) => onChange({ ...props, caption })}
/>
<SelectField
label="Alignment"
value={props.alignment || 'center'}
onChange={(alignment) => onChange({ ...props, alignment })}
options={ALIGN}
/>
</div>
)
}
registerBlock({
type: 'image',
version: 1,
label: 'Image',
icon: '🖼',
component: ImageBlock,
editor: ImageEditor,
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
})

View File

@@ -0,0 +1,43 @@
// quote block — a pull quote with optional attribution.
import { registerBlock } from '../registry'
import { TextAreaField, TextField } from '../editorKit.jsx'
function QuoteBlock({ props }) {
if (!props.text) return null
return (
<figure className="page-quote">
<blockquote>{props.text}</blockquote>
{props.attribution && <figcaption> {props.attribution}</figcaption>}
</figure>
)
}
function QuoteEditor({ props, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<TextAreaField
label="Quote"
value={props.text}
rows={3}
maxLength={1000}
onChange={(text) => onChange({ ...props, text })}
/>
<TextField
label="Attribution (optional)"
value={props.attribution}
maxLength={200}
onChange={(attribution) => onChange({ ...props, attribution })}
/>
</div>
)
}
registerBlock({
type: 'quote',
version: 1,
label: 'Quote',
icon: '❝',
component: QuoteBlock,
editor: QuoteEditor,
defaults: () => ({ text: '', attribution: '' }),
})

View File

@@ -0,0 +1,39 @@
// rich_text block — HTML from the shared rich-text editor. Rendered inside the
// same `.prose` styling as wiki/news bodies, sanitized on render as defense in
// depth (the server also sanitizes on save).
import { lazy, Suspense } from 'react'
import DOMPurify from 'dompurify'
import { registerBlock } from '../registry'
const RichTextEditor = lazy(() => import('../../components/RichTextEditor.jsx'))
function RichTextBlock({ props }) {
return (
<div
className="prose page-rich-text"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(props.html || '') }}
/>
)
}
function RichTextEditorForm({ props, onChange }) {
return (
<Suspense fallback={<span className="spin" />}>
<RichTextEditor
value={props.html || ''}
onChange={(html) => onChange({ ...props, html })}
variant="post"
/>
</Suspense>
)
}
registerBlock({
type: 'rich_text',
version: 1,
label: 'Rich text',
icon: '¶',
component: RichTextBlock,
editor: RichTextEditorForm,
defaults: () => ({ html: '' }),
})

View File

@@ -0,0 +1,120 @@
// two_column block — the only container. Holds two ordered arrays of sub-blocks
// (`left`, `right`). Sub-blocks are leaf blocks only (no nested containers — the
// one-level cap the server also enforces), so the column editor's palette is the
// set of non-container registered blocks.
import { registerBlock, getBlock, listBlocks, makeBlockId } from '../registry'
import BlockRenderer from '../BlockRenderer.jsx'
// ── Renderer ──────────────────────────────────────────────────────────
function TwoColumnBlock({ props }) {
const left = Array.isArray(props.left) ? props.left : []
const right = Array.isArray(props.right) ? props.right : []
return (
<div className="page-two-column">
<div className="page-column">
{left.map((b) => (
<BlockRenderer key={b.id} block={b} />
))}
</div>
<div className="page-column">
{right.map((b) => (
<BlockRenderer key={b.id} block={b} />
))}
</div>
</div>
)
}
// ── Column editor ─────────────────────────────────────────────────────
// Manages one side's array: add (from the leaf palette), edit each via its own
// registry editor, reorder, remove.
function ColumnEditor({ title, items, onChange }) {
const list = Array.isArray(items) ? items : []
const palette = listBlocks().filter((b) => !b.container)
function addBlock(type) {
const def = getBlock(type)
if (!def) return
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
onChange([...list, block])
}
function updateAt(i, nextProps) {
onChange(list.map((b, j) => (j === i ? { ...b, props: nextProps } : b)))
}
function removeAt(i) {
onChange(list.filter((_, j) => j !== i))
}
function move(i, dir) {
const j = i + dir
if (j < 0 || j >= list.length) return
const next = [...list]
;[next[i], next[j]] = [next[j], next[i]]
onChange(next)
}
return (
<div className="pb-column-editor">
<div className="pb-column-head">
<span className="field-label" style={{ margin: 0 }}>{title}</span>
<select
className="select pb-add-select"
value=""
onChange={(e) => {
if (e.target.value) addBlock(e.target.value)
e.target.value = ''
}}
>
<option value="">+ Add block</option>
{palette.map((b) => (
<option key={b.type} value={b.type}>
{b.label}
</option>
))}
</select>
</div>
{list.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>Empty column.</p>
)}
{list.map((block, i) => {
const def = getBlock(block.type)
const Editor = def?.editor
return (
<div key={block.id} className="pb-subblock">
<div className="pb-subblock-head">
<span className="sans dim" style={{ fontSize: '0.78rem' }}>{def?.label || block.type}</span>
<div className="pb-subblock-actions">
<button type="button" className="pill pb-mini" disabled={i === 0} onClick={() => move(i, -1)} title="Move up"></button>
<button type="button" className="pill pb-mini" disabled={i === list.length - 1} onClick={() => move(i, 1)} title="Move down"></button>
<button type="button" className="pill pb-mini" onClick={() => removeAt(i)} title="Remove"></button>
</div>
</div>
{Editor && <Editor props={block.props || {}} onChange={(p) => updateAt(i, p)} />}
</div>
)
})}
</div>
)
}
function TwoColumnEditor({ props, onChange }) {
return (
<div className="pb-two-column-editor">
<ColumnEditor title="Left column" items={props.left} onChange={(left) => onChange({ ...props, left })} />
<ColumnEditor title="Right column" items={props.right} onChange={(right) => onChange({ ...props, right })} />
</div>
)
}
registerBlock({
type: 'two_column',
version: 1,
label: 'Two columns',
icon: '▥',
component: TwoColumnBlock,
editor: TwoColumnEditor,
defaults: () => ({ left: [], right: [] }),
container: true,
containerSlots: ['left', 'right'],
})

View File

@@ -740,3 +740,147 @@ button[disabled] {
grid-template-columns: 1fr;
}
}
/* ===== CMS page builder — public block rendering ===== */
/* Vertical rhythm between top-level blocks on a rendered page. */
.page-blocks > * + * {
margin-top: 26px;
}
.page-heading {
font-family: var(--serif, Georgia, serif);
color: var(--ink);
line-height: 1.2;
}
.page-divider {
border: none;
border-top: 1px solid var(--line);
margin: 8px 0;
}
/* image block */
.page-image {
margin: 0;
}
.page-image img {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--line);
display: block;
}
.page-image figcaption {
margin-top: 8px;
color: var(--muted);
font-family: var(--sans);
font-size: 0.82rem;
}
.page-image--center {
text-align: center;
}
.page-image--center img,
.page-image--center figcaption {
margin-left: auto;
margin-right: auto;
}
.page-image--right {
text-align: right;
}
.page-image--right img,
.page-image--right figcaption {
margin-left: auto;
}
.page-image--full img {
width: 100%;
}
/* cta block */
.page-cta-wrap {
display: flex;
}
.page-cta--secondary {
background: transparent;
color: var(--ink);
}
/* quote block */
.page-quote {
margin: 0;
border-left: 3px solid var(--accent);
padding: 4px 0 4px 20px;
}
.page-quote blockquote {
margin: 0;
font-size: 1.15rem;
line-height: 1.5;
color: var(--ink);
font-style: italic;
}
.page-quote figcaption {
margin-top: 8px;
color: var(--muted);
font-family: var(--sans);
font-size: 0.85rem;
}
/* two-column block */
.page-two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.page-column > * + * {
margin-top: 18px;
}
@media (max-width: 720px) {
.page-two-column {
grid-template-columns: 1fr;
}
}
/* ===== CMS page builder — column sub-block editor ===== */
.pb-two-column-editor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 720px) {
.pb-two-column-editor {
grid-template-columns: 1fr;
}
}
.pb-column-editor {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: var(--panel-flat, transparent);
}
.pb-column-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.pb-add-select {
width: auto;
padding: 6px 10px;
font-size: 0.82rem;
}
.pb-subblock {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
margin-top: 10px;
background: var(--bg);
}
.pb-subblock-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.pb-subblock-actions {
display: flex;
gap: 4px;
}
.pb-mini {
min-width: 28px;
padding: 3px 8px;
font-size: 0.8rem;
line-height: 1;
}