Add page builder admin UI + public page route (step 5 + step 6 client)

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

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:54:36 -05:00
parent 4d87c5f627
commit 1dd7603f54
8 changed files with 758 additions and 7 deletions

View File

@@ -13,12 +13,12 @@ const logger = require('../../../utils/logger')('pages')
// sometimes a block-error list); anything else is an unexpected 500.
function fail(res, err) {
if (err && err.name === 'PageError') {
const body = { error: err.message, code: err.code }
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({ error: 'Internal error' })
return res.status(500).json({ message: 'Internal error' })
}
async function listPages(req, res) {
@@ -27,7 +27,7 @@ async function listPages(req, res) {
async function getPage(req, res) {
const page = await pages.getById(Number(req.params.id))
if (!page) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
return res.json(page)
}
@@ -48,7 +48,7 @@ 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({ error: 'Page not found', code: 'not_found' })
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 } })
@@ -86,13 +86,13 @@ async function unprotectPage(req, res) {
const id = Number(req.params.id)
const password = req.body?.password
if (typeof password !== 'string' || password === '') {
return res.status(400).json({ error: 'Password is required', code: 'password_required' })
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({ error: 'Password is incorrect', code: 'bad_password' })
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 } })
@@ -108,7 +108,7 @@ 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({ error: 'Page not found', code: 'not_found' })
if (!page) return res.status(404).json({ message: 'Page not found', code: 'not_found' })
const t = token.signPagePreview(id)
return res.json({
token: t,