- 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>
92 lines
3.5 KiB
JavaScript
92 lines
3.5 KiB
JavaScript
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>
|
|
)
|
|
}
|