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:
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
26
client/src/blocks/BlockRenderer.jsx
Normal file
26
client/src/blocks/BlockRenderer.jsx
Normal 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} />)
|
||||
}
|
||||
64
client/src/blocks/editorKit.jsx
Normal file
64
client/src/blocks/editorKit.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
client/src/blocks/index.js
Normal file
19
client/src/blocks/index.js
Normal 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'
|
||||
84
client/src/blocks/registry.js
Normal file
84
client/src/blocks/registry.js
Normal 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}`
|
||||
}
|
||||
63
client/src/blocks/types/cta.jsx
Normal file
63
client/src/blocks/types/cta.jsx
Normal 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' }),
|
||||
})
|
||||
25
client/src/blocks/types/divider.jsx
Normal file
25
client/src/blocks/types/divider.jsx
Normal 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: () => ({}),
|
||||
})
|
||||
46
client/src/blocks/types/heading.jsx
Normal file
46
client/src/blocks/types/heading.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// heading block — plain-text section heading (h1–h4). 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: '' }),
|
||||
})
|
||||
100
client/src/blocks/types/image.jsx
Normal file
100
client/src/blocks/types/image.jsx
Normal 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' }),
|
||||
})
|
||||
43
client/src/blocks/types/quote.jsx
Normal file
43
client/src/blocks/types/quote.jsx
Normal 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: '' }),
|
||||
})
|
||||
39
client/src/blocks/types/richText.jsx
Normal file
39
client/src/blocks/types/richText.jsx
Normal 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: '' }),
|
||||
})
|
||||
120
client/src/blocks/types/twoColumn.jsx
Normal file
120
client/src/blocks/types/twoColumn.jsx
Normal 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'],
|
||||
})
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
453
client/src/routes/admin/views/PageBuilder.jsx
Normal file
453
client/src/routes/admin/views/PageBuilder.jsx
Normal 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 & 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 & 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 & 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 & danger zone</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 8 }}>
|
||||
A protected page can’t 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>
|
||||
)
|
||||
}
|
||||
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal file
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
client/src/routes/public/CmsPage.jsx
Normal file
56
client/src/routes/public/CmsPage.jsx
Normal 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 isn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
server/src/blocks/index.js
Normal file
30
server/src/blocks/index.js
Normal 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,
|
||||
}
|
||||
84
server/src/blocks/propHelpers.js
Normal file
84
server/src/blocks/propHelpers.js
Normal 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,
|
||||
}
|
||||
103
server/src/blocks/registry.js
Normal file
103
server/src/blocks/registry.js
Normal 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,
|
||||
}
|
||||
47
server/src/blocks/sanitizeBlocks.js
Normal file
47
server/src/blocks/sanitizeBlocks.js
Normal 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 }
|
||||
22
server/src/blocks/types/cta.js
Normal file
22
server/src/blocks/types/cta.js
Normal 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
|
||||
},
|
||||
})
|
||||
12
server/src/blocks/types/divider.js
Normal file
12
server/src/blocks/types/divider.js
Normal 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, [])
|
||||
},
|
||||
})
|
||||
21
server/src/blocks/types/heading.js
Normal file
21
server/src/blocks/types/heading.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// heading — a section heading. `level` picks the tag (h1–h4), `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
|
||||
},
|
||||
})
|
||||
27
server/src/blocks/types/image.js
Normal file
27
server/src/blocks/types/image.js
Normal 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
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/quote.js
Normal file
20
server/src/blocks/types/quote.js
Normal 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
|
||||
},
|
||||
})
|
||||
26
server/src/blocks/types/richText.js
Normal file
26
server/src/blocks/types/richText.js
Normal 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) }
|
||||
},
|
||||
})
|
||||
20
server/src/blocks/types/twoColumn.js
Normal file
20
server/src/blocks/types/twoColumn.js
Normal 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'])
|
||||
},
|
||||
})
|
||||
119
server/src/blocks/validateBlocks.js
Normal file
119
server/src/blocks/validateBlocks.js
Normal 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 }
|
||||
79
server/src/model/pages/pages.db.js
Normal file
79
server/src/model/pages/pages.db.js
Normal 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 }
|
||||
306
server/src/model/pages/pages.model.js
Normal file
306
server/src/model/pages/pages.model.js
Normal 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,
|
||||
}
|
||||
28
server/src/model/pages/reservedSlugs.js
Normal file
28
server/src/model/pages/reservedSlugs.js
Normal 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 }
|
||||
@@ -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',
|
||||
|
||||
131
server/src/router/v1/admin/pages.controller.js
Normal file
131
server/src/router/v1/admin/pages.controller.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user