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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user