Merge pull request 'CMS Page Builder (Wave 1): block-based Pages content type' (#47) from feature/cms-page-builder into main

Reviewed-on: UOM/website#47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-10 02:15:05 +00:00
44 changed files with 3799 additions and 0 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",
@@ -1483,6 +1484,19 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"diff": "^5.2.2",

View File

@@ -18,12 +18,15 @@ import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
// Admin
import AdminLogin from './routes/admin/AdminLogin.jsx'
import AdminLayout from './routes/admin/AdminLayout.jsx'
import Dashboard from './routes/admin/views/Dashboard.jsx'
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
@@ -65,8 +68,15 @@ export default function App() {
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
</Route>
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
preview link works regardless of site mode. */}
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
{/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route
@@ -79,6 +89,9 @@ export default function App() {
>
<Route index element={<Dashboard />} />
<Route path="posts" element={<PostsAdmin />} />
<Route path="pages" element={<PagesAdmin />} />
<Route path="pages/new" element={<PageBuilder />} />
<Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
<Route path="settings" element={<SettingsAdmin />} />

View File

@@ -73,6 +73,10 @@ export const api = {
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
// is fetched by id + token.
page: (slug) => req(`/public/pages/${slug}`),
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- admin -----
@@ -97,6 +101,15 @@ export const api = {
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
// ----- CMS pages (block-based page builder) -----
listPages: () => req('/admin/pages'),
getPage: (id) => req(`/admin/pages/${id}`),
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
unprotectPage: (id, password) =>
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),

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

@@ -0,0 +1,19 @@
// Client block registry entrypoint. Importing this module registers every
// browser-side block definition (renderer + editor + palette entry) exactly
// once, then re-exports the registry API. The page builder and the public page
// renderer should import from HERE, not ./registry, so the definitions are
// loaded before anything reads the registry.
//
// Wave 1 definitions are registered below as each block is built (spec build
// order step 3), one import per block.
export * from './registry'
// ── 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,84 @@
// Block registry (client side) — mirrors the server registry
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
// React renderer, the admin edit form, and the palette icon/label. The page
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
// page renderer all read from this registry, so adding a block later is one
// entry here (plus its server-side schema entry) rather than edits scattered
// across the builder and renderer.
//
// A registered definition looks like:
// {
// type: 'heading', // must match the server registry type
// version: 1, // must match the server schema version
// label: 'Heading', // palette display name
// icon: 'heading', // palette icon key
// component: HeadingBlock, // renderer: (props) => JSX
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
// defaults: () => ({ ... }), // starting props when a block is added
// container: false, // true only for two_column
// containerSlots: [], // ['left','right'] for two_column
// }
//
// This module only defines the pattern; Wave 1 definitions register via
// ./index.js as each block is built (spec build order step 3).
const registry = new Map()
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
// stored block object. Exported so the builder can construct envelopes without
// hard-coding the shape.
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
/**
* Register a block definition. Throws on a duplicate type — a programmer error
* caught at module load, not runtime.
* @param {object} def
* @returns {object} the stored definition
*/
export function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: block type already registered: ${def.type}`)
}
const entry = {
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
label: def.label || def.type,
icon: def.icon || null,
component: def.component || null,
editor: def.editor || null,
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
container: Boolean(def.container),
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
}
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
export function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
export function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
export function listBlocks() {
return [...registry.values()]
}
/**
* Generate a stable block id. Called once when a block is added to the canvas;
* never derived from array position, so a reorder keeps ids intact (they are the
* React key and the future revision-history join point).
* @returns {string}
*/
export function makeBlockId() {
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
return `b_${rand}`
}

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

@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import { api } from '../api/client.js'
// Toolbar button.
@@ -25,6 +26,22 @@ function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
}
// Alignment glyph: three lines justified to the given side.
function AlignIcon({ align }) {
const rows = {
left: [[2, 14], [2, 10], [2, 12]],
center: [[2, 14], [4, 12], [3, 13]],
right: [[2, 14], [6, 14], [4, 14]],
}[align]
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
{rows.map(([x1, x2], i) => (
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
))}
</svg>
)
}
// Toolbar variants:
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
// 'post' — full minus the wiki-page picker (no page-list context in posts).
@@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({ openOnClick: false, autolink: true }),
Image.configure({ inline: false }),
// Alignment stored as `text-align` on the block node (heading/paragraph),
// so it round-trips through save/reload as inline style. Shared here means
// every consumer — post editor, and the future rich_text / two_column
// blocks — gets it for free.
TextAlign.configure({ types: ['heading', 'paragraph'] }),
],
content: value || '',
onUpdate: ({ editor }) => onChange(editor.getHTML()),
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
</Btn>
<span className="rte-sep" />
<Btn title="Align left" active={editor.isActive({ textAlign: 'left' })} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
<AlignIcon align="left" />
</Btn>
<Btn title="Align center" active={editor.isActive({ textAlign: 'center' })} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
<AlignIcon align="center" />
</Btn>
<Btn title="Align right" active={editor.isActive({ textAlign: 'right' })} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
<AlignIcon align="right" />
</Btn>
<span className="rte-sep" />
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
🔗
</Btn>

View File

@@ -27,6 +27,7 @@ function Icon({ children, size = 16 }) {
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
@@ -52,6 +53,7 @@ const NAV = [
title: 'Content',
items: [
{ to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
{ to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
{ to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
{ to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
],
@@ -85,6 +87,7 @@ const COLLAPSE_KEY = 'admin.nav.collapsed'
const TITLES = {
'/admin': 'Dashboard',
'/admin/posts': 'Posts',
'/admin/pages': 'Pages',
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',

View File

@@ -0,0 +1,453 @@
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>
)
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { shortDate } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// List of CMS pages. Create/edit open the full-page block builder; the builder
// owns save/delete/publish so this view is read-only navigation.
export default function PagesAdmin() {
const navigate = useNavigate()
const [tick] = useState(0)
const { loading, error, data } = useAsync(() => api.admin.listPages(), [tick])
const pages = data || []
const openNew = useCallback(() => navigate('/admin/pages/new'), [navigate])
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, marginBottom: 18 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
Compose pages from blocks. A published page is live at <code>/its-slug</code>.
</p>
<button onClick={openNew} className="btn btn-primary btn-sq">
+ New page
</button>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load pages." />}
{!loading && !error && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Title</th>
<th className="adm-th">Slug</th>
<th className="adm-th">Status</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{pages.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No pages yet create your first one.
</td>
</tr>
)}
{pages.map((p) => (
<tr key={p.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{p.title}
{p.protected && (
<span title="Protected" style={{ marginLeft: 8 }}>🔒</span>
)}
</td>
<td className="adm-td dim">/{p.slug}</td>
<td className="adm-td">
<span className={`badge ${p.status === 'published' ? 'badge-pub' : 'badge-draft'}`}>
{p.status === 'published' ? 'Published' : 'Draft'}
</span>
</td>
<td className="adm-td dim">{shortDate(p.updatedAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{p.status === 'published' && (
<a
className="link-accent"
href={`/${p.slug}`}
target="_blank"
rel="noreferrer"
style={{ marginRight: 14 }}
>
View
</a>
)}
<span className="link-accent" onClick={() => navigate(`/admin/pages/${p.id}`)}>
Edit
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import '../../blocks/index.js' // registers all block types
import { BlockList } from '../../blocks/BlockRenderer.jsx'
// Renders a CMS page composed of blocks. Two modes:
// - live: /:slug → fetches the published page (staff see drafts)
// - preview: /preview/:id/:token → fetches the current state via a token,
// regardless of publish status (draft-preview links).
export default function CmsPage({ preview = false }) {
const params = useParams()
const { loading, error, data: page } = useAsync(
() => (preview ? api.pagePreview(params.id, params.token) : api.page(params.slug)),
[preview, params.id, params.token, params.slug],
)
// Reflect the page's title + meta description while it's mounted, then restore.
useEffect(() => {
if (!page) return
const prevTitle = document.title
document.title = page.metadata?.seoTitle || page.title || prevTitle
return () => {
document.title = prevTitle
}
}, [page])
const layout = page?.settings?.layout || 'default'
const widthClass = layout === 'full_width' || layout === 'landing' ? 'shell-wide' : 'shell'
return (
<PublicLayout section="website">
<div className={`${widthClass} page-body`} style={{ paddingTop: 40 }}>
{preview && page && (
<div className="page-preview-banner sans">
Preview this is the current draft state and isnt publicly visible.
</div>
)}
{loading && <Loading />}
{error && (
<ErrorState
message={error.status === 404 ? 'That page could not be found.' : 'Could not load this page.'}
/>
)}
{page && (
<article className={`page-blocks page-layout--${layout}`}>
<BlockList blocks={page.blocks} />
</article>
)}
</div>
</PublicLayout>
)
}

View File

@@ -79,6 +79,10 @@ a {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
}
.shell-wide {
width: min(1280px, calc(100% - 32px));
margin: 0 auto;
}
.page {
min-height: 100vh;
display: flex;
@@ -740,3 +744,265 @@ 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;
}
/* ===== CMS page builder — admin canvas ===== */
.pb-toolbar {
display: flex;
align-items: center;
gap: 10px;
position: sticky;
top: 0;
z-index: 5;
padding: 10px 0;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.pb-error {
border: 1px solid #6e3b38;
background: rgba(110, 59, 56, 0.16);
color: #e6a9a3;
border-radius: 8px;
padding: 10px 14px;
margin-top: 14px;
font-size: 0.86rem;
}
.pb-notice {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
padding: 8px 14px;
margin-top: 14px;
font-size: 0.86rem;
}
.pb-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
margin-bottom: 18px;
}
.pb-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-family: var(--sans);
font-size: 0.9rem;
padding: 10px 16px;
cursor: pointer;
}
.pb-tab.is-active {
color: var(--ink);
border-bottom-color: var(--accent);
}
.pb-palette {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line);
border-radius: 10px;
margin-bottom: 16px;
}
.pb-canvas {
display: flex;
flex-direction: column;
gap: 14px;
}
.pb-block-card {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-flat, transparent);
}
.pb-block-card.is-dragging {
opacity: 0.5;
}
.pb-block-card.is-hidden {
opacity: 0.6;
}
.pb-block-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.pb-drag {
cursor: grab;
color: var(--muted);
user-select: none;
}
.pb-block-body {
padding: 14px;
}
.pb-settings {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 720px;
}
.pb-danger {
border-color: #6e3b38;
color: #d98b84;
}
.pb-danger:hover:not([disabled]) {
background: rgba(110, 59, 56, 0.18);
border-color: #8a4b47;
}
/* Draft-preview banner on the public renderer. */
.page-preview-banner {
border: 1px solid var(--accent);
background: var(--blue);
color: var(--accent-bright);
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 20px;
font-size: 0.85rem;
text-align: center;
}

View File

@@ -486,6 +486,43 @@ CREATE TABLE IF NOT EXISTS mod_notes (
INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Generic CMS pages composed from a fixed palette of blocks (the page builder).
-- `blocks` is a JSON array of block-envelope objects ({ id, type, version,
-- visible, props }); it is stored as text and parsed/validated in app code
-- against the block registry (server/src/blocks) on every save — the same
-- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and
-- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/
-- robots and layout/nav_* columns are metadata/settings surfaced grouped in the
-- API response; several have no consumer yet but are cheap to add now and painful
-- to retrofit once real pages exist. published_at mirrors posts: stamped the first
-- time a page goes to 'published'.
CREATE TABLE IF NOT EXISTS pages (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(160) NOT NULL UNIQUE,
title VARCHAR(200) NOT NULL,
blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
protected TINYINT(1) NOT NULL DEFAULT 0,
author_id INT NULL,
-- SEO / social metadata (grouped under `metadata` in the API response).
seo_title VARCHAR(200) NULL,
meta_description VARCHAR(400) NULL,
og_image VARCHAR(500) NULL,
canonical_url VARCHAR(500) NULL,
robots VARCHAR(100) NULL,
-- Presentation / navigation (grouped under `settings` in the API response).
layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default',
show_in_nav TINYINT(1) NOT NULL DEFAULT 0,
nav_group ENUM('main','footer','account','hidden') NULL,
nav_order INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at DATETIME NULL,
CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_pages_status (status),
INDEX idx_pages_nav (show_in_nav, nav_group, nav_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -66,6 +66,20 @@ function verifyTotpChallenge(token) {
return decoded
}
// Short-lived, unguessable link token for previewing a (possibly unpublished)
// CMS page. Carries purpose:'page_preview' + the page id and nothing else; it is
// NOT a session (session validation rejects it) and only grants read of that one
// page's current block state. Default 1h expiry per the page-builder spec.
function signPagePreview(pageId, { expiresIn = '1h' } = {}) {
return jwt.sign({ pageId, purpose: 'page_preview' }, JWT_SECRET, { expiresIn })
}
function verifyPagePreview(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.purpose !== 'page_preview') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
@@ -122,6 +136,8 @@ module.exports = {
verifyToken,
signTotpChallenge,
verifyTotpChallenge,
signPagePreview,
verifyPagePreview,
cookieMaxAge,
cookieSecure,
cookieOptions,

View File

@@ -0,0 +1,30 @@
// Block registry entrypoint. Requiring this module registers every server-side
// block definition (schema + cache policy) exactly once, then re-exports the
// registry API and the blocks validator. Anything that needs to validate a
// page's blocks or look up a block type should require THIS module, not
// ./registry directly, so the definitions are guaranteed to be loaded.
//
// Wave 1 block definitions are registered below, one require() per block (each
// module self-registers on load). Requiring THIS module guarantees they are all
// present before anything validates a page's blocks.
const registry = require('./registry')
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
const { sanitizeBlocks } = require('./sanitizeBlocks')
// ── Wave 1 block definitions (self-register on require) ────────────────
require('./types/heading')
require('./types/richText')
require('./types/image')
require('./types/twoColumn')
require('./types/cta')
require('./types/divider')
require('./types/quote')
module.exports = {
...registry,
validateBlocks,
sanitizeBlocks,
MAX_BLOCKS,
MAX_SUBBLOCKS,
}

View File

@@ -0,0 +1,84 @@
// Small shared validators used by the Wave 1 block schemas. Each block's schema
// composes these and returns a flat array of error strings; validateBlocks
// prefixes each with the block path (so 'text is required' becomes
// 'blocks[2].props.text is required'). Phrase messages to read well after that
// prefix — start with the prop name.
/** @returns {boolean} true if v is a non-empty (after trim) string. */
function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0
}
/**
* Accept a same-origin relative URL ("/uploads/x.png", "/wiki/foo") or an
* absolute http/https URL. Rejects javascript:, data:, protocol-relative
* ("//evil"), and anything else — the block renderers drop these into hrefs/src
* so this is a security boundary, not just a format check.
* @param {unknown} v
* @returns {boolean}
*/
function isSafeUrl(v) {
if (typeof v !== 'string' || v.trim() === '') return false
const s = v.trim()
if (s.startsWith('//')) return false // protocol-relative — ambiguous origin
if (s.startsWith('/')) return true // same-origin relative
try {
const u = new URL(s)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/**
* Build an enum validator for a prop.
* @param {string} name prop name (for the message)
* @param {string[]} allowed
* @returns {(v: unknown) => string|null} error string or null
*/
function oneOf(name, allowed) {
return (v) => (allowed.includes(v) ? null : `${name} must be one of ${allowed.join(', ')}`)
}
/**
* Validate a required text prop: present, non-empty, within maxLen.
* @returns {string|null}
*/
function requiredText(name, v, maxLen) {
if (!isNonEmptyString(v)) return `${name} is required`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Validate an optional text prop: if present it must be a string within maxLen.
* @returns {string|null}
*/
function optionalText(name, v, maxLen) {
if (v === undefined || v === null || v === '') return null
if (typeof v !== 'string') return `${name} must be a string`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Reject any prop key not in `allowed`. Keeps a block's props tight so nothing
* unexpected is smuggled through and stored.
* @returns {string[]} error strings
*/
function onlyKeys(props, allowed) {
const errors = []
for (const key of Object.keys(props)) {
if (!allowed.includes(key)) errors.push(`${key} is not an allowed prop`)
}
return errors
}
module.exports = {
isNonEmptyString,
isSafeUrl,
oneOf,
requiredText,
optionalText,
onlyKeys,
}

View File

@@ -0,0 +1,103 @@
// Block registry (server side) — the single source of truth for what block
// types exist, how their props validate, and how long a rendered block may be
// cached. The admin builder UI, the public renderer, and this server-side
// validation are all driven from a registry entry rather than a switch statement
// scattered across files: adding a block later means adding ONE entry (here on
// the server for schema/cache, and one in client/src/blocks for the React
// renderer/editor), not editing four places.
//
// A registered definition looks like:
// {
// type: 'heading', // stable string id, unique across the registry
// version: 1, // prop-schema version; bump when props change so a
// // one-time migration can transform older blocks
// schema: (props) => [], // returns an array of error strings ([] = valid)
// sanitize: (props) => props, // optional normalizer run on save AFTER
// // validation, e.g. rich_text runs its html through
// // the shared allowlist; returns cleaned props
// cacheTTL: null, // seconds a rendered instance may be cached;
// // null = never cache (static blocks). Dynamic
// // Wave 2 blocks set this (e.g. server_status: 10).
// container: false, // true only for block types that hold sub-blocks
// containerSlots: [], // prop keys holding sub-block arrays, e.g.
// // ['left','right'] for two_column
// }
//
// This module is intentionally empty of block types — it only defines the
// pattern. Wave 1 block definitions register themselves via ./index.js.
// The only keys allowed at the top level of a stored block object. Everything
// block-specific lives inside `props`; nothing else lives at the top level.
// Ordering is the array position, not a stored field — so a reorder is just a
// reorder of the array, and `id` is never derived from position.
const RESERVED_KEYS = Object.freeze(['id', 'type', 'version', 'visible', 'props'])
const registry = new Map()
/**
* Register a block definition. Throws on a missing type or a duplicate — both
* are programmer errors surfaced at boot, not runtime input.
* @param {object} def
* @returns {object} the normalized, frozen definition
*/
function registerBlock(def) {
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
throw new Error('registerBlock: a block definition needs a string `type`')
}
if (registry.has(def.type)) {
throw new Error(`registerBlock: block type already registered: ${def.type}`)
}
if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerBlock: ${def.type}.schema must be a function`)
}
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerBlock: ${def.type}.sanitize must be a function`)
}
const containerSlots = def.containerSlots || []
if (def.container && containerSlots.length === 0) {
throw new Error(`registerBlock: container block ${def.type} needs containerSlots`)
}
const entry = Object.freeze({
type: def.type,
version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null,
sanitize: def.sanitize || null,
cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL),
container: Boolean(def.container),
containerSlots: Object.freeze([...containerSlots]),
})
registry.set(entry.type, entry)
return entry
}
/** @returns {object|null} the definition for `type`, or null if unknown. */
function getBlock(type) {
return registry.get(type) || null
}
/** @returns {boolean} whether `type` is a registered block. */
function hasBlock(type) {
return registry.has(type)
}
/** @returns {object[]} all registered definitions (registration order). */
function listBlocks() {
return [...registry.values()]
}
/**
* Drop every registered block. Test-only — lets a suite register a fixture set
* and start from a known-empty registry.
*/
function _resetRegistry() {
registry.clear()
}
module.exports = {
RESERVED_KEYS,
registerBlock,
getBlock,
hasBlock,
listBlocks,
_resetRegistry,
}

View File

@@ -0,0 +1,47 @@
// Normalize + sanitize a validated blocks array before persisting. Runs AFTER
// validateBlocks (which guarantees the envelope/prop shape), so this can assume
// well-formed input and focus on: applying each block's registry `sanitize`
// normalizer (e.g. rich_text runs its html through the allowlist), stamping the
// registry `version`, defaulting `visible` to true, and recursing one level into
// container slots. Returns a new array; never mutates the input.
const { getBlock } = require('./registry')
function sanitizeBlocks(blocks) {
if (!Array.isArray(blocks)) return []
return blocks.map(sanitizeOne)
}
function sanitizeOne(block) {
const def = getBlock(block.type)
if (!def) return block // unreachable after validation, but stay defensive
let props = block.props && typeof block.props === 'object' ? { ...block.props } : {}
// Recurse into container slots first (leaf sub-blocks get sanitized too).
if (def.container) {
for (const slot of def.containerSlots) {
if (Array.isArray(props[slot])) props[slot] = props[slot].map(sanitizeOne)
}
}
// Apply the block's own normalizer last (operates on its scalar props).
if (def.sanitize) {
try {
props = def.sanitize(props)
} catch {
// Leave props as-is; validation already passed, a sanitize throw shouldn't
// block the save.
}
}
return {
id: block.id,
type: block.type,
version: Number.isInteger(block.version) ? block.version : def.version,
visible: block.visible !== false,
props,
}
}
module.exports = { sanitizeBlocks }

View File

@@ -0,0 +1,22 @@
// cta — a call-to-action button. `text` is the label, `url` the destination
// (same-origin path or http/https), `style` picks primary/secondary appearance.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, requiredText, onlyKeys } = require('../propHelpers')
const STYLES = ['primary', 'secondary']
const MAX_TEXT = 100
registerBlock({
type: 'cta',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'url', 'style'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
if (!isSafeUrl(props.url)) errors.push('url must be a same-origin path or http(s) URL')
const style = oneOf('style', STYLES)(props.style)
if (style) errors.push(style)
return errors
},
})

View File

@@ -0,0 +1,12 @@
// divider — a pure spacer / horizontal rule. Carries no props.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'divider',
version: 1,
cacheTTL: null,
schema(props) {
return onlyKeys(props, [])
},
})

View File

@@ -0,0 +1,21 @@
// heading — a section heading. `level` picks the tag (h1h4), `text` is plain
// text (the renderer escapes it; no HTML here — use rich_text for markup).
const { registerBlock } = require('../registry')
const { oneOf, requiredText, onlyKeys } = require('../propHelpers')
const LEVELS = ['h1', 'h2', 'h3', 'h4']
const MAX_TEXT = 200
registerBlock({
type: 'heading',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['level', 'text'])
const level = oneOf('level', LEVELS)(props.level)
if (level) errors.push(level)
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
return errors
},
})

View File

@@ -0,0 +1,27 @@
// image — a single image with optional caption. `src` must be a same-origin
// upload path or an http/https URL (isSafeUrl); `alignment` controls layout.
// Stays URL-based until the Wave 3 asset picker lands, then src swaps to an
// asset id via a small migration.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, optionalText, onlyKeys } = require('../propHelpers')
const ALIGNMENTS = ['left', 'center', 'right', 'full']
const MAX_ALT = 300
const MAX_CAPTION = 500
registerBlock({
type: 'image',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['src', 'alt', 'caption', 'alignment'])
if (!isSafeUrl(props.src)) errors.push('src must be a same-origin path or http(s) URL')
const alt = optionalText('alt', props.alt, MAX_ALT)
if (alt) errors.push(alt)
const caption = optionalText('caption', props.caption, MAX_CAPTION)
if (caption) errors.push(caption)
const alignment = oneOf('alignment', ALIGNMENTS)(props.alignment)
if (alignment) errors.push(alignment)
return errors
},
})

View File

@@ -0,0 +1,20 @@
// quote — a pull quote with optional attribution.
const { registerBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys } = require('../propHelpers')
const MAX_TEXT = 1000
const MAX_ATTRIB = 200
registerBlock({
type: 'quote',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'attribution'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
const attribution = optionalText('attribution', props.attribution, MAX_ATTRIB)
if (attribution) errors.push(attribution)
return errors
},
})

View File

@@ -0,0 +1,26 @@
// rich_text — a block of HTML authored in the shared rich-text editor. Validated
// only for type/size here; the actual safety comes from `sanitize`, which runs
// the html through the same allowlist (cleanBody) used for posts/wiki bodies, so
// a direct API call can't smuggle unsafe markup past the editor.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
const { cleanBody } = require('../../utils/sanitizeHtml')
const MAX_HTML = 50000
registerBlock({
type: 'rich_text',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['html'])
if (typeof props.html !== 'string') errors.push('html must be a string')
else if (props.html.length > MAX_HTML) {
errors.push(`html must be at most ${MAX_HTML} characters`)
}
return errors
},
sanitize(props) {
return { ...props, html: cleanBody(props.html) }
},
})

View File

@@ -0,0 +1,20 @@
// two_column — the only container block. Holds two ordered arrays of sub-blocks
// (`left`, `right`). The sub-block arrays are validated by validateBlocks, which
// also enforces the one-level nesting cap (a column may not contain another
// container). This schema only guards the prop shape; the slot arrays default to
// empty when absent.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'two_column',
version: 1,
cacheTTL: null,
container: true,
containerSlots: ['left', 'right'],
schema(props) {
// Slot array contents are validated by validateBlocks' container handling;
// here we only reject stray props.
return onlyKeys(props, ['left', 'right'])
},
})

View File

@@ -0,0 +1,119 @@
// Server-side validation for a page's `blocks` array, run on every save before
// persisting. The admin UI validates client-side too, but that can be bypassed
// by a direct API call, so this is the authoritative gate: it enforces the block
// envelope (reserved keys only), that every `type` is a registered block, that
// each block's props satisfy the registry schema, and the one-level nesting cap
// (only container blocks may hold sub-blocks, and sub-blocks may not themselves
// be containers).
//
// Returns { valid, errors } — a flat list of human-readable error strings, each
// prefixed with the path to the offending block (e.g. `blocks[2].props.text`).
// It never throws on bad input; callers turn a non-empty `errors` into a 400.
const { getBlock, RESERVED_KEYS } = require('./registry')
// Bound the payload so a single page can't carry an unreasonable block tree.
const MAX_BLOCKS = 100 // top-level blocks per page
const MAX_SUBBLOCKS = 50 // sub-blocks per container slot
const ID_RE = /^[A-Za-z0-9_-]{1,40}$/
/**
* Validate a stored blocks array against the registry.
* @param {unknown} blocks
* @returns {{ valid: boolean, errors: string[] }}
*/
function validateBlocks(blocks) {
const errors = []
if (!Array.isArray(blocks)) {
return { valid: false, errors: ['blocks must be an array'] }
}
if (blocks.length > MAX_BLOCKS) {
errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`)
}
const seenIds = new Set()
blocks.forEach((block, i) => {
validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false })
})
return { valid: errors.length === 0, errors }
}
/**
* Validate one block envelope in place. `nested` = true when validating a
* sub-block inside a container slot, which forbids further nesting.
*/
function validateBlock(block, path, seenIds, errors, { nested }) {
if (block === null || typeof block !== 'object' || Array.isArray(block)) {
errors.push(`${path} must be an object`)
return
}
// Envelope: only the reserved keys, nothing smuggled at the top level.
for (const key of Object.keys(block)) {
if (!RESERVED_KEYS.includes(key)) {
errors.push(`${path}.${key} is not an allowed top-level key`)
}
}
// id — stable, unique across the whole page (top-level and nested share one
// namespace since ids are the future join point for revision history).
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
errors.push(`${path}.id must be a short id string`)
} else if (seenIds.has(block.id)) {
errors.push(`${path}.id duplicates another block id (${block.id})`)
} else {
seenIds.add(block.id)
}
// visible — optional in input, but if present must be a boolean.
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
errors.push(`${path}.visible must be a boolean`)
}
// props — always an object bag.
const props = block.props
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
errors.push(`${path}.props must be an object`)
}
// type — must resolve to a registered block.
const def = typeof block.type === 'string' ? getBlock(block.type) : null
if (!def) {
errors.push(`${path}.type is not a registered block type (${String(block.type)})`)
return // can't validate props or nesting without a definition
}
// Per-block prop schema from the registry.
if (def.schema && props && typeof props === 'object') {
let schemaErrors = []
try {
schemaErrors = def.schema(props) || []
} catch (err) {
schemaErrors = [`schema threw: ${err.message}`]
}
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
}
// Nesting: only container blocks may hold sub-blocks, capped at one level.
if (def.container) {
if (nested) {
errors.push(`${path} is a container and may not be nested inside another container`)
return
}
for (const slot of def.containerSlots) {
const sub = props ? props[slot] : undefined
if (sub === undefined) continue // an empty slot is allowed
if (!Array.isArray(sub)) {
errors.push(`${path}.props.${slot} must be an array of blocks`)
continue
}
if (sub.length > MAX_SUBBLOCKS) {
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
}
sub.forEach((child, j) => {
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
})
}
}
}
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }

View File

@@ -0,0 +1,79 @@
const { query } = require('../../utils/db')
// `blocks` is stored as a JSON string (MEDIUMTEXT) and parsed in the model.
const COLS = [
'id', 'slug', 'title', 'blocks', 'status', 'protected', 'author_id',
'seo_title', 'meta_description', 'og_image', 'canonical_url', 'robots',
'layout', 'show_in_nav', 'nav_group', 'nav_order',
'created_at', 'updated_at', 'published_at',
].join(', ')
// Admin list — every page, newest first. Excludes the (potentially large)
// blocks payload; callers that need it fetch the row by id/slug.
async function listSummaries() {
return query(
`SELECT id, slug, title, status, protected, show_in_nav, nav_group, nav_order,
updated_at, published_at
FROM pages ORDER BY updated_at DESC, id DESC`,
)
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findBySlug(slug) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
// Insert a fully-formed column map. `blocks` must already be a JSON string.
async function insert(page) {
const res = await query(
`INSERT INTO pages
(slug, title, blocks, status, protected, author_id,
seo_title, meta_description, og_image, canonical_url, robots,
layout, show_in_nav, nav_group, nav_order, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
page.slug,
page.title,
page.blocks,
page.status,
page.protected ? 1 : 0,
page.author_id ?? null,
page.seo_title ?? null,
page.meta_description ?? null,
page.og_image ?? null,
page.canonical_url ?? null,
page.robots ?? null,
page.layout ?? 'default',
page.show_in_nav ? 1 : 0,
page.nav_group ?? null,
page.nav_order ?? null,
page.published_at ?? null,
],
)
return res.insertId
}
// Update only the provided columns. Keys must be real column names (the model
// builds this map from a whitelist, never straight from the request body).
async function update(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE pages SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function remove(id) {
return query('DELETE FROM pages WHERE id = ?', [id])
}
module.exports = { listSummaries, findById, findBySlug, insert, update, remove }

View File

@@ -0,0 +1,306 @@
// CMS pages model. Owns the rules the API surface must not bypass:
// - blocks are validated against the block registry and sanitized on every
// save (the authoritative gate — a direct API call can't skip it);
// - the DB row is mapped to/from the grouped API shape (metadata / settings);
// - slug is validated + reserved-checked at create and is immutable after;
// - `protected` can be turned ON via a normal update but only OFF via the
// dedicated unprotect path (see unprotect()), enforced here regardless of
// what the request body contains.
//
// Business/validation failures throw a PageError carrying an HTTP status + code
// so the controller can translate without knowing the rules.
const pagesDb = require('./pages.db')
const { isReservedSlug } = require('./reservedSlugs')
const { validateBlocks, sanitizeBlocks } = require('../../blocks')
const LAYOUTS = ['default', 'full_width', 'landing']
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
const STATUSES = ['draft', 'published']
const SLUG_RE = /^[a-z0-9-]+$/
const MAX_SLUG = 160
class PageError extends Error {
constructor(status, code, message, extra) {
super(message)
this.name = 'PageError'
this.status = status
this.code = code
if (extra) Object.assign(this, extra)
}
}
// ── Serialization (row → API shape) ───────────────────────────────────
function parseBlocks(raw) {
if (raw == null || raw === '') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function serialize(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
blocks: parseBlocks(row.blocks),
metadata: {
seoTitle: row.seo_title,
metaDescription: row.meta_description,
ogImage: row.og_image,
canonicalUrl: row.canonical_url,
robots: row.robots,
},
settings: {
layout: row.layout,
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
protected: Boolean(row.protected),
},
authorId: row.author_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
function serializeSummary(row) {
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
protected: Boolean(row.protected),
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
// ── Field validation / mapping ────────────────────────────────────────
function assertSlug(slug) {
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
}
if (isReservedSlug(slug)) {
throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
}
}
function assertStatus(status) {
if (status !== undefined && !STATUSES.includes(status)) {
throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
}
}
// Validate + sanitize blocks; returns a JSON string ready to store.
function buildBlocks(blocks) {
const { valid, errors } = validateBlocks(blocks)
if (!valid) {
throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
}
return JSON.stringify(sanitizeBlocks(blocks))
}
// Map the grouped `metadata` object to DB columns. Only keys present in the
// input are returned, so a PATCH touches only what it sends.
function mapMetadata(metadata) {
const cols = {}
if (!metadata || typeof metadata !== 'object') return cols
const strOrNull = (v, max, field) => {
if (v === null || v === undefined || v === '') return null
if (typeof v !== 'string' || v.length > max) {
throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
}
return v
}
if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
return cols
}
// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
const cols = {}
if (!settings || typeof settings !== 'object') return cols
if ('layout' in settings) {
if (!LAYOUTS.includes(settings.layout)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
cols.layout = settings.layout
}
if ('showInNav' in settings) {
if (typeof settings.showInNav !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
cols.show_in_nav = settings.showInNav ? 1 : 0
}
if ('navGroup' in settings) {
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
cols.nav_group = settings.navGroup
}
if ('navOrder' in settings) {
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
cols.nav_order = settings.navOrder
}
return cols
}
// ── Reads ─────────────────────────────────────────────────────────────
async function list() {
const rows = await pagesDb.listSummaries()
return rows.map(serializeSummary)
}
async function getById(id) {
return serialize(await pagesDb.findById(id))
}
// Public read by slug. Non-admins only see published pages (returns null for a
// draft so the caller can 404 it indistinguishably from a missing page).
async function getBySlug(slug, { includeUnpublished = false } = {}) {
const row = await pagesDb.findBySlug(slug)
if (!row) return null
if (!includeUnpublished && row.status !== 'published') return null
return serialize(row)
}
// Raw row (for the controller's protected/status checks without re-serializing).
async function getRawById(id) {
return pagesDb.findById(id)
}
// ── Writes ────────────────────────────────────────────────────────────
async function create(input, authorId) {
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
assertSlug(slug)
assertStatus(status)
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
const row = {
slug,
title: title.trim(),
blocks: buildBlocks(blocks),
status,
author_id: authorId,
...mapMetadata(metadata),
...mapSettings(settings),
protected: settings && settings.protected === true ? 1 : 0,
published_at: status === 'published' ? new Date() : null,
}
let id
try {
id = await pagesDb.insert(row)
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
}
throw err
}
return getById(id)
}
async function update(id, patch) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
// slug is immutable after create — reject an attempt rather than silently
// ignoring it, so the caller knows their change didn't take.
if (patch.slug !== undefined && patch.slug !== current.slug) {
throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
}
const fields = {}
if (patch.title !== undefined) {
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
fields.title = patch.title.trim()
}
if (patch.blocks !== undefined) {
fields.blocks = buildBlocks(patch.blocks)
}
if (patch.status !== undefined) {
assertStatus(patch.status)
fields.status = patch.status
// Stamp published_at the first time a page becomes published.
if (patch.status === 'published' && !current.published_at) {
fields.published_at = new Date()
}
}
Object.assign(fields, mapMetadata(patch.metadata))
Object.assign(fields, mapSettings(patch.settings))
// Protected transitions: ON is allowed here; OFF is not (must go through the
// password-gated unprotect endpoint), regardless of the request body.
if (patch.settings && 'protected' in patch.settings) {
const want = patch.settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
// want === false while already unprotected → no-op.
}
await pagesDb.update(id, fields)
return getById(id)
}
async function remove(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
if (current.protected) {
throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
}
await pagesDb.remove(id)
return { id }
}
// Flip protected → false. The controller performs the password step-up before
// calling this; the model just applies it.
async function unprotect(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
await pagesDb.update(id, { protected: 0 })
return getById(id)
}
module.exports = {
PageError,
LAYOUTS,
NAV_GROUPS,
STATUSES,
serialize,
list,
getById,
getBySlug,
getRawById,
create,
update,
remove,
unprotect,
}

View File

@@ -0,0 +1,28 @@
// Slugs a CMS page may not claim, because a top-level page lives at `/:slug` and
// must never shadow an existing named route (SPA route or API namespace). The
// catch-all page route is matched only after these, but reserving the names up
// front gives the admin a clear "that slug is reserved" error at create time
// instead of a silently unreachable page.
//
// Kept as a Set of lowercase single-segment slugs. Page slugs are validated to a
// single segment (^[a-z0-9-]+$) so we only need to guard first path segments.
const RESERVED_SLUGS = new Set([
// API / infrastructure
'api', 'internal', 'uploads', 'assets', 'static', 'public',
// Auth / account
'login', 'logout', 'register', 'account', 'auth',
// Admin app
'admin',
// Existing top-level SPA sections
'site', 'wiki', 'news', 'newsletter', 'screenshots', 'five-on-friday', 'about', 'status',
// Page-builder's own surface
'pages', 'preview',
])
/** @returns {boolean} true if `slug` collides with a reserved route name. */
function isReservedSlug(slug) {
return RESERVED_SLUGS.has(String(slug).toLowerCase())
}
module.exports = { RESERVED_SLUGS, isReservedSlug }

View File

@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -475,6 +476,99 @@ adminRouter.delete(
ctrl.deleteWiki,
)
// ── CMS Pages (block-based page builder) ──────────────────────────────
adminRouter.get(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'List all CMS pages (summaries)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
pagesCtrl.listPages,
)
adminRouter.post(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Create a CMS page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('slug').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate,
pagesCtrl.createPage,
)
adminRouter.get(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.getPage,
)
adminRouter.patch(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.updatePage,
)
adminRouter.delete(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.deletePage,
)
adminRouter.post(
'/pages/:id/unprotect',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('password').isString().notEmpty(),
validate,
pagesCtrl.unprotectPage,
)
adminRouter.post(
'/pages/:id/preview',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.createPreview,
)
// ── Settings ──────────────────────────────────────────────────────────
adminRouter.get(
'/settings',

View File

@@ -0,0 +1,131 @@
// Admin CMS pages controller. Thin HTTP layer over pages.model — it translates
// the model's PageError (status + code) into responses and records audit-log
// entries for the lifecycle events the spec calls out (create / publish /
// unpublish / delete, protect on, and the password-gated unprotect).
const pages = require('../../../model/pages/pages.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const token = require('../../../auth/token')
const logger = require('../../../utils/logger')('pages')
// Map a thrown error to a response. Known PageErrors carry a status + code (and
// sometimes a block-error list); anything else is an unexpected 500.
function fail(res, err) {
if (err && err.name === 'PageError') {
const body = { message: err.message, code: err.code }
if (err.errors) body.details = err.errors
return res.status(err.status).json(body)
}
logger.error('unexpected pages error', { error: err.message })
return res.status(500).json({ message: 'Internal error' })
}
async function listPages(req, res) {
return res.json(await pages.list())
}
async function getPage(req, res) {
const page = await pages.getById(Number(req.params.id))
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
return res.json(page)
}
async function createPage(req, res) {
try {
const page = await pages.create(req.body, req.user.id)
await activity.log({ req, action: 'page.create', detail: { id: page.id, slug: page.slug } })
if (page.status === 'published') {
await activity.log({ req, action: 'page.publish', detail: { id: page.id, slug: page.slug } })
}
return res.status(201).json(page)
} catch (err) {
return fail(res, err)
}
}
async function updatePage(req, res) {
try {
const id = Number(req.params.id)
const before = await pages.getRawById(id)
if (!before) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
const page = await pages.update(id, req.body)
await activity.log({ req, action: 'page.update', detail: { id, slug: page.slug } })
// Emit dedicated audit events for the transitions the spec singles out.
if (before.status !== page.status) {
const action = page.status === 'published' ? 'page.publish' : 'page.unpublish'
await activity.log({ req, action, detail: { id, slug: page.slug } })
}
if (!before.protected && page.settings.protected) {
await activity.log({ req, action: 'page.protect', detail: { id, slug: page.slug } })
}
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
async function deletePage(req, res) {
try {
const id = Number(req.params.id)
const result = await pages.remove(id)
await activity.log({ req, action: 'page.delete', detail: { id } })
return res.json(result)
} catch (err) {
return fail(res, err)
}
}
// Step-up auth: verify the CURRENT admin's password against their own hash
// (independent of JWT validity) before flipping protected → false. On failure:
// no mutation, standard 401, and the entered password is never logged anywhere.
async function unprotectPage(req, res) {
try {
const id = Number(req.params.id)
const password = req.body?.password
if (typeof password !== 'string' || password === '') {
return res.status(400).json({ message: 'Password is required', code: 'password_required' })
}
const user = await users.getRawById(req.user.id)
const ok = await users.validatePassword(user, password)
if (!ok) {
logger.warn('failed page unprotect (bad password)', { pageId: id, userId: req.user.id })
return res.status(401).json({ message: 'Password is incorrect', code: 'bad_password' })
}
const page = await pages.unprotect(id)
await activity.log({ req, action: 'page.unprotect', detail: { id, slug: page.slug } })
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
// Mint a 1h preview token for the page's current (possibly unpublished) state.
// Returns the token plus the ready-to-use public preview path.
async function createPreview(req, res) {
try {
const id = Number(req.params.id)
const page = await pages.getById(id)
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
const t = token.signPagePreview(id)
return res.json({
token: t,
expiresInSeconds: 3600,
path: `/api/v1/public/pages/${id}/preview/${t}`,
})
} catch (err) {
return fail(res, err)
}
}
module.exports = {
listPages,
getPage,
createPage,
updatePage,
deletePage,
unprotectPage,
createPreview,
}

View File

@@ -1,10 +1,21 @@
const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const pages = require('../../../model/pages/pages.model')
const mailer = require('../../../utils/mailer')
const { getUserFromRequest } = require('../../../utils/auth')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('public')
// Staff (non-player) roles may see draft pages on the public route; everyone else
// gets a 404 for a draft, indistinguishable from a missing page.
const STAFF_ROLES = ['admin', 'editor', 'moderator']
function isStaff(req) {
const user = getUserFromRequest(req)
return Boolean(user && STAFF_ROLES.includes(user.role))
}
async function getSettings(req, res) {
try {
return res.json(await settings.getPublic())
@@ -100,6 +111,34 @@ async function getWikiPage(req, res) {
}
}
async function getPage(req, res) {
try {
// Staff see drafts (live preview); the public sees published pages only.
const page = await pages.getBySlug(req.params.slug, { includeUnpublished: isStaff(req) })
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Token-gated draft preview: renders the page's current block state regardless of
// status, for anyone holding the (short-lived, unguessable) link.
async function getPagePreview(req, res) {
try {
const id = Number(req.params.id)
const decoded = token.verifyPagePreview(req.params.token)
if (!decoded || decoded.pageId !== id) {
return res.status(404).json({ message: 'Preview not found or expired' })
}
const page = await pages.getById(id)
if (!page) return res.status(404).json({ message: 'Not found' })
return res.json(page)
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function contact(req, res) {
const { name, email, message } = req.body
try {
@@ -120,5 +159,7 @@ module.exports = {
getWikiTags,
getWikiList,
getWikiPage,
getPage,
getPagePreview,
contact,
}

View File

@@ -102,4 +102,30 @@ publicRouter.get(
ctrl.getWikiPage,
)
// ── CMS pages (block-based) ────────────────────────────────────────────
// Preview is registered before /pages/:slug and is NOT site-mode gated, so a
// draft-preview link keeps working during maintenance. The token itself is the
// access control.
publicRouter.get(
'/pages/:id/preview/:token',
// #swagger.tags = ['Public']
// #swagger.summary = 'Render a page from a draft-preview token'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Preview token from POST /admin/pages/:id/preview.' }
/* #swagger.responses[200] = { description: 'The page (any status)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Token invalid/expired or page missing', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getPagePreview,
)
publicRouter.get(
'/pages/:slug',
// #swagger.tags = ['Public']
// #swagger.summary = 'Get a published CMS page by slug'
// #swagger.description = 'Drafts 404 for the public; staff sessions see drafts. Gated by site mode.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page slug.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getPage,
)
module.exports = publicRouter

View File

@@ -18,6 +18,19 @@ const OPTIONS = {
span: ['data-wiki-slug'], // marks internal wiki links (used from Phase 3)
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
// Block alignment from the rich-text editor. `style` is only honored for the
// properties/values whitelisted in allowedStyles below — everything else in
// the style attribute is stripped.
p: ['style'],
h1: ['style'], h2: ['style'], h3: ['style'],
h4: ['style'], h5: ['style'], h6: ['style'],
},
// Restrict inline styles to text-align (left/right/center/justify) only. Any
// other CSS property, or an unlisted value, is discarded.
allowedStyles: {
'*': {
'text-align': [/^(left|right|center|justify)$/],
},
},
// http/https for links and images, mailto for links, plus relative URLs so
// uploaded images (/uploads/...) and internal links (/wiki/...) pass through.

View File

@@ -1179,6 +1179,110 @@
}
}
},
"/api/v1/public/pages/{id}/preview/{token}": {
"get": {
"tags": [
"Public"
],
"summary": "Render a page from a draft-preview token",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Preview token from POST /admin/pages/:id/preview."
}
],
"responses": {
"200": {
"description": "The page (any status)",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"404": {
"description": "Token invalid/expired or page missing",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/pages/{slug}": {
"get": {
"tags": [
"Public"
],
"summary": "Get a published CMS page by slug",
"description": "Drafts 404 for the public; staff sessions see drafts. Gated by site mode.",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Page slug."
}
],
"responses": {
"200": {
"description": "The page",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/admin/account": {
"get": {
"tags": [
@@ -1206,6 +1310,9 @@
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
@@ -3194,6 +3301,464 @@
]
}
},
"/api/v1/admin/pages": {
"get": {
"tags": [
"Admin · Pages"
],
"summary": "List all CMS pages (summaries)",
"description": "",
"responses": {
"200": {
"description": "Page summaries",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Pages"
],
"summary": "Create a CMS page",
"description": "",
"responses": {
"201": {
"description": "Created page",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Invalid slug / title / blocks / metadata / settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Slug already exists",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"slug": {
"type": "string"
},
"title": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"draft",
"published"
]
},
"blocks": {
"type": "array",
"items": {
"type": "object"
}
},
"metadata": {
"type": "object"
},
"settings": {
"type": "object"
}
}
}
}
}
}
}
},
"/api/v1/admin/pages/{id}": {
"get": {
"tags": [
"Admin · Pages"
],
"summary": "Get a CMS page by id (full, incl. blocks)",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
}
],
"responses": {
"200": {
"description": "The page",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"patch": {
"tags": [
"Admin · Pages"
],
"summary": "Update a CMS page (title, status, blocks, metadata, settings)",
"description": "slug is immutable; disabling protection is rejected here (use /unprotect).",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
}
],
"responses": {
"200": {
"description": "Updated page",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Validation error (slug immutable, invalid blocks, etc.)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Disabling protection requires /unprotect",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"delete": {
"tags": [
"Admin · Pages"
],
"summary": "Delete a CMS page (blocked if protected)",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
}
],
"responses": {
"200": {
"description": "Deleted (echoes the id)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletedId"
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Page is protected",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/pages/{id}/unprotect": {
"post": {
"tags": [
"Admin · Pages"
],
"summary": "Disable page protection (password step-up re-auth)",
"description": "Verifies the current admin password server-side, then flips protected → false.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
}
],
"responses": {
"200": {
"description": "Updated page (protected=false)",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Password incorrect",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
},
"required": [
"password"
]
}
}
}
}
}
},
"/api/v1/admin/pages/{id}/preview": {
"post": {
"tags": [
"Admin · Pages"
],
"summary": "Mint a 1h draft-preview link for a page",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Page id."
}
],
"responses": {
"200": {
"description": "Preview token + path",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"token": {
"type": "string"
},
"expiresInSeconds": {
"type": "integer"
},
"path": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/settings": {
"get": {
"tags": [
@@ -3638,6 +4203,367 @@
}
}
},
"/api/v1/admin/email/config": {
"get": {
"tags": [
"Admin · Email"
],
"summary": "Get email delivery config + status (admin only)",
"description": "",
"responses": {
"200": {
"description": "Config (refresh token stripped) + status",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"put": {
"tags": [
"Admin · Email"
],
"summary": "Update email delivery config (admin only)",
"description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.",
"responses": {
"200": {
"description": "Updated config",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Cannot enable before connecting a mailbox",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"senderName": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
}
}
}
}
},
"/api/v1/admin/email/connect/start": {
"get": {
"tags": [
"Admin · Email"
],
"summary": "Begin the Gmail OAuth2 connect flow (admin only)",
"description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.",
"responses": {
"200": {
"description": "Authorization URL",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Google OAuth client not configured",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/email/connect/callback": {
"get": {
"tags": [
"Admin · Email"
],
"summary": "OAuth2 callback — stores the refresh token, redirects to Settings",
"description": "",
"parameters": [
{
"name": "code",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "state",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "error",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"302": {
"description": "Redirect back to /admin/settings"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/email/test": {
"post": {
"tags": [
"Admin · Email"
],
"summary": "Send a test email (admin only)",
"description": "",
"responses": {
"200": {
"description": "Sent",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sent": {
"type": "boolean"
},
"to": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"502": {
"description": "Send failed / not configured",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"to": {
"type": "string",
"format": "email"
}
}
}
}
}
}
}
},
"/api/v1/admin/email/disconnect": {
"post": {
"tags": [
"Admin · Email"
],
"summary": "Disconnect Gmail and disable email (admin only)",
"description": "",
"responses": {
"200": {
"description": "Disconnected config",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/auth/providers": {
"get": {
"tags": [