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

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