diff --git a/client/src/App.jsx b/client/src/App.jsx index e83ae99..99a54dc 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> } /> } /> + {/* CMS pages: top-level /:slug, matched only after the named routes + above (React Router ranks static routes over this dynamic one). */} + } /> + {/* Draft-preview link (token-gated). Outside the maintenance gate so a + preview link works regardless of site mode. */} + } /> + {/* Admin */} } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 2ea408d..3357c1a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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 }), diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3ae46d2..9cf3c69 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -27,6 +27,7 @@ function Icon({ children, size = 16 }) { const IconHome = () => const IconPosts = () => const IconWiki = () => +const IconPages = () => const IconActivity = () => const IconShield = () => const IconUsers = () => @@ -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', diff --git a/client/src/routes/admin/views/PageBuilder.jsx b/client/src/routes/admin/views/PageBuilder.jsx new file mode 100644 index 0000000..d8f38da --- /dev/null +++ b/client/src/routes/admin/views/PageBuilder.jsx @@ -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 + + const published = form.status === 'published' + + return ( +
+ {/* Toolbar */} +
+ + {published ? 'Published' : 'Draft'} +
+ {isEdit && ( + + )} + {isEdit && ( + + )} + +
+ + {error && ( +
+ {error} + {details.length > 0 && ( +
    + {details.map((d, i) =>
  • {d}
  • )} +
+ )} +
+ )} + {notice &&
{notice}
} + + {/* Title + slug */} +
+ + +
+ + {/* Tabs */} +
+ + +
+ + {tab === 'content' && ( + <> +
+ Add block + {palette.map((b) => ( + + ))} +
+ +
+ {form.blocks.length === 0 && ( +

+ No blocks yet — add one from the palette above. +

+ )} + {form.blocks.map((block, i) => { + const def = getBlock(block.type) + const Editor = def?.editor + const hidden = block.visible === false + return ( +
setDragIndex(i)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => onDrop(i)} + onDragEnd={() => setDragIndex(null)} + > +
+ + {def?.label || block.type} +
+ + + + +
+
+ {Editor ? ( + updateBlock(block.id, p)} /> + ) : ( +

Unknown block type: {block.type}

+ )} +
+
+ ) + })} +
+ + )} + + {tab === 'settings' && ( +
+
+

SEO & metadata

+
+ + + + + +
+
+ +
+

Layout & navigation

+
+ + + + setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." /> +
+
+ +
+

Protection & danger zone

+

+ A protected page can’t be deleted and its protection can only be removed by re-entering your password. +

+ {!isEdit &&

Save the page first to manage protection.

} + {isEdit && ( +
+ {protectedNow ? ( + + ) : ( + + )} + +
+ )} +
+
+ )} + + {pwModal && ( + setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} /> + )} +
+ ) +} + +function UnprotectModal({ onCancel, onConfirm, busy, error }) { + const [pw, setPw] = useState('') + return ( + + + + + } + > +

+ Removing protection is a sensitive change — re-enter your account password to continue. +

+ setPw(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)} + placeholder="Password" + /> + {error &&

{error}

} +
+ ) +} diff --git a/client/src/routes/admin/views/PagesAdmin.jsx b/client/src/routes/admin/views/PagesAdmin.jsx new file mode 100644 index 0000000..9473f19 --- /dev/null +++ b/client/src/routes/admin/views/PagesAdmin.jsx @@ -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 ( +
+
+

+ Compose pages from blocks. A published page is live at /its-slug. +

+ +
+ + {loading && } + {error && } + + {!loading && !error && ( +
+ + + + + + + + + + + {pages.length === 0 && ( + + + + )} + {pages.map((p) => ( + + + + + + + + ))} + +
TitleSlugStatusUpdated +
+ No pages yet — create your first one. +
+ {p.title} + {p.protected && ( + 🔒 + )} + /{p.slug} + + {p.status === 'published' ? 'Published' : 'Draft'} + + {shortDate(p.updatedAt)} + {p.status === 'published' && ( + + View + + )} + navigate(`/admin/pages/${p.id}`)}> + Edit + +
+
+ )} +
+ ) +} diff --git a/client/src/routes/public/CmsPage.jsx b/client/src/routes/public/CmsPage.jsx new file mode 100644 index 0000000..9610ca1 --- /dev/null +++ b/client/src/routes/public/CmsPage.jsx @@ -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 ( + +
+ {preview && page && ( +
+ Preview — this is the current draft state and isn’t publicly visible. +
+ )} + {loading && } + {error && ( + + )} + {page && ( +
+ +
+ )} +
+
+ ) +} diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index 55dac7a..e033861 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -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; @@ -884,3 +888,121 @@ button[disabled] { 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; +} diff --git a/server/src/router/v1/admin/pages.controller.js b/server/src/router/v1/admin/pages.controller.js index a29ae0d..a28b851 100644 --- a/server/src/router/v1/admin/pages.controller.js +++ b/server/src/router/v1/admin/pages.controller.js @@ -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,