diff --git a/client/package-lock.json b/client/package-lock.json index 7c50d39..834e8bf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "diff": "^5.2.2", @@ -1483,6 +1484,19 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz", + "integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-text-style": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", diff --git a/client/package.json b/client/package.json index 4b1948f..250c40d 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "diff": "^5.2.2", 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/blocks/BlockRenderer.jsx b/client/src/blocks/BlockRenderer.jsx new file mode 100644 index 0000000..e78e94f --- /dev/null +++ b/client/src/blocks/BlockRenderer.jsx @@ -0,0 +1,26 @@ +// Renders stored blocks via their registry component. Used by the public page +// route, the draft preview, and (recursively) the two_column block. Kept +// separate from the registry so both the renderer and the builder can import it. +// Import the lookup from the registry directly (not ./index) to avoid a cycle: +// index → types/twoColumn → BlockRenderer. The page route/builder import ./index, +// which registers every block before anything renders. +import { getBlock } from './registry.js' + +/** + * Render one block. A block with `visible === false` renders nothing (admins + * hide blocks without deleting them). An unknown type also renders nothing — + * server validation prevents storing one, so this only guards a client/server + * registry skew rather than crashing the whole page. + */ +export default function BlockRenderer({ block }) { + if (!block || block.visible === false) return null + const def = getBlock(block.type) + if (!def || !def.component) return null + const Component = def.component + return +} + +/** Render an ordered array of blocks (array position = display order). */ +export function BlockList({ blocks }) { + return (blocks || []).map((block) => ) +} diff --git a/client/src/blocks/editorKit.jsx b/client/src/blocks/editorKit.jsx new file mode 100644 index 0000000..481fb21 --- /dev/null +++ b/client/src/blocks/editorKit.jsx @@ -0,0 +1,64 @@ +// Shared form controls for block editors, styled with the existing admin design +// system (.field-label / .input / .select). Every block's editor is a +// ({ props, onChange }) component; these keep the seven of them consistent and +// short. onChange always receives the full next props object. + +export function Field({ label, hint, children }) { + return ( + + ) +} + +export function TextField({ label, hint, value, onChange, placeholder, maxLength }) { + return ( + + onChange(e.target.value)} + /> + + ) +} + +export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) { + return ( + +