Voeg CMS voor de Publieke Website toe (overgenomen van rikx-org)

Pagina-editor, hoofdmenu, SEO en media onder Publieke Website → Pagina's
(/website), los van het CMS in rikx-org: eigen tabellen in de beheerdatabase.
Blokken (hero, tekstblok, testimonial, voordelenlijst, call-to-action) met
Quill, NL/EN/DE met terugval op NL, en afbeeldingsupload naar MEDIA_DIR met
een openbare /media-route (CSP-sandbox).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:27:12 +02:00
parent f9e4a1aa5d
commit 9be692974e
19 changed files with 2505 additions and 87 deletions
+356
View File
@@ -0,0 +1,356 @@
import path from "path";
import { randomBytes } from "crypto";
import { mkdir, writeFile } from "fs/promises";
import { readdirSync } from "fs";
import { getDb } from "./db";
import { DEFAULT_LANG, LANGS, type Lang } from "./i18n";
import type { RichtextVariant } from "./richtextVariants";
// Publieke Website (CMS): pagina's, content-blokken, menu, SEO en media.
// Overgenomen van rikx-org (/portaal/paginas), maar los daarvan: eigen tabellen
// in de beheerdatabase (schema in lib/db.ts). Er zijn hier geen vaste pagina's;
// alle pagina's zijn zelf aangemaakt en vrij op te bouwen uit blokken.
// Media staan naast de database op hetzelfde volume (/data/media in productie)
const DB_PATH = process.env.DB_PATH || path.join(/* turbopackIgnore: true */ process.cwd(), "data", "app.db");
const MEDIA_DIR = process.env.MEDIA_DIR || path.join(path.dirname(DB_PATH), "media");
export type PageSlug = string;
// Slugs die niet als paginanaam gekozen mogen worden (taalprefixen en vaste routes)
export const RESERVED_SLUGS = ["en", "de", "media", "sitemap.xml", "robots.txt", "favicon.ico"];
export type BlockType = "hero" | "richtext" | "testimonial" | "benefits" | "cta";
export const BLOCK_TYPES: BlockType[] = ["hero", "richtext", "testimonial", "benefits", "cta"];
export type Page = {
slug: PageSlug;
meta_title: string;
meta_description: string;
og_image: string | null;
canonical_url: string | null;
noindex: 0 | 1;
nav_label: string | null;
nav_order: number;
updated_at: string;
};
export type ContentBlock = {
id: number;
page_slug: PageSlug;
block_key: string;
type: BlockType;
label: string;
content: string; // JSON, vorm afhankelijk van `type`
sort_order: number;
};
export type HeroContent = { heading: string; text: string; image: string; buttonLabel?: string; buttonHref?: string };
export type RichtextContent = {
heading: string;
body: string;
buttonLabel?: string;
buttonHref?: string;
variant?: RichtextVariant; // ontbreekt = "standaard"
};
export type TestimonialContent = { quote: string; attribution: string; image: string };
export type BenefitsContent = { heading: string; items: string[] };
export type CtaContent = { heading: string; body: string; buttonLabel: string; buttonHref: string };
export type PageTranslation = { meta_title: string; meta_description: string; nav_label: string };
// --- Pagina's en menu ---
export function listPages(): Page[] {
return (getDb().prepare("SELECT * FROM pages ORDER BY slug").all() as Page[]).map((row) => ({ ...row }));
}
export function getPage(slug: PageSlug): Page | undefined {
const row = getDb().prepare("SELECT * FROM pages WHERE slug = ?").get(slug) as Page | undefined;
return row ? { ...row } : undefined;
}
export function listNavPages(): Page[] {
return (
getDb()
.prepare("SELECT * FROM pages WHERE nav_label IS NOT NULL AND nav_label != '' ORDER BY nav_order, slug")
.all() as Page[]
).map((row) => ({ ...row }));
}
function nextNavOrder(): number {
return (
getDb().prepare("SELECT COALESCE(MAX(nav_order), 0) + 1 AS n FROM pages WHERE nav_label IS NOT NULL").get() as {
n: number;
}
).n;
}
export function createPage(slug: string, navLabel: string, metaTitle: string) {
getDb()
.prepare("INSERT INTO pages (slug, meta_title, nav_label, nav_order) VALUES (?, ?, ?, ?)")
.run(slug, metaTitle, navLabel, nextNavOrder());
}
// Blokken en vertalingen gaan mee via ON DELETE CASCADE
export function deletePage(slug: PageSlug) {
getDb().prepare("DELETE FROM pages WHERE slug = ?").run(slug);
}
export function updatePageSeo(
slug: PageSlug,
metaTitle: string,
metaDescription: string,
ogImage: string | null,
canonicalUrl: string | null,
noindex: boolean
) {
getDb()
.prepare(
`UPDATE pages
SET meta_title = ?, meta_description = ?, og_image = ?, canonical_url = ?, noindex = ?, updated_at = datetime('now')
WHERE slug = ?`
)
.run(metaTitle, metaDescription, ogImage, canonicalUrl, noindex ? 1 : 0, slug);
}
// Leeg label haalt de pagina uit het menu. Komt ze (weer) in het menu, dan
// achteraan; de volgorde van de andere pagina's blijft ongemoeid.
export function updatePageNavLabel(slug: PageSlug, navLabel: string | null) {
const db = getDb();
const clean = navLabel?.trim() || null;
if (clean === null) {
db.prepare("UPDATE pages SET nav_label = NULL WHERE slug = ?").run(slug);
return;
}
const current = db.prepare("SELECT nav_label FROM pages WHERE slug = ?").get(slug) as
| { nav_label: string | null }
| undefined;
if (current && !current.nav_label) {
db.prepare("UPDATE pages SET nav_label = ?, nav_order = ? WHERE slug = ?").run(clean, nextNavOrder(), slug);
} else {
db.prepare("UPDATE pages SET nav_label = ? WHERE slug = ?").run(clean, slug);
}
}
// Wisselt nav_order met de buur in de menuvolgorde; niets aan de rand
export function movePageNav(slug: PageSlug, direction: "up" | "down") {
const siblings = listNavPages();
const index = siblings.findIndex((p) => p.slug === slug);
const swapWith = direction === "up" ? index - 1 : index + 1;
if (index === -1 || swapWith < 0 || swapWith >= siblings.length) return;
const a = siblings[index];
const b = siblings[swapWith];
const db = getDb();
db.prepare("UPDATE pages SET nav_order = ? WHERE slug = ?").run(b.nav_order, a.slug);
db.prepare("UPDATE pages SET nav_order = ? WHERE slug = ?").run(a.nav_order, b.slug);
}
// --- Content-blokken ---
export function listContentBlocks(slug: PageSlug): ContentBlock[] {
return (
getDb().prepare("SELECT * FROM content_blocks WHERE page_slug = ? ORDER BY sort_order, id").all(slug) as ContentBlock[]
).map((row) => ({ ...row }));
}
export function getContentBlock(id: number): ContentBlock | undefined {
const row = getDb().prepare("SELECT * FROM content_blocks WHERE id = ?").get(id) as ContentBlock | undefined;
return row ? { ...row } : undefined;
}
export function updateBlockContent(id: number, content: object) {
getDb().prepare("UPDATE content_blocks SET content = ? WHERE id = ?").run(JSON.stringify(content), id);
}
function defaultBlockContent(type: BlockType): object {
switch (type) {
case "hero":
return { heading: "", text: "", image: "", buttonLabel: "", buttonHref: "" } satisfies HeroContent;
case "richtext":
return { heading: "", body: "", buttonLabel: "", buttonHref: "" } satisfies RichtextContent;
case "testimonial":
return { quote: "", attribution: "", image: "" } satisfies TestimonialContent;
case "benefits":
return { heading: "", items: [] } satisfies BenefitsContent;
case "cta":
return { heading: "", body: "", buttonLabel: "", buttonHref: "" } satisfies CtaContent;
}
}
// sort_order altijd hier berekend (MAX+1), zodat een nieuw blok nooit botst met een bestaand blok
export function createContentBlock(pageSlug: PageSlug, blockKey: string, type: BlockType, label: string): number {
const db = getDb();
const { n } = db
.prepare("SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM content_blocks WHERE page_slug = ?")
.get(pageSlug) as { n: number };
const result = db
.prepare("INSERT INTO content_blocks (page_slug, block_key, type, label, content, sort_order) VALUES (?, ?, ?, ?, ?, ?)")
.run(pageSlug, blockKey, type, label, JSON.stringify(defaultBlockContent(type)), n);
return Number(result.lastInsertRowid);
}
export function deleteContentBlock(id: number) {
getDb().prepare("DELETE FROM content_blocks WHERE id = ?").run(id);
}
// Wisselt sort_order met de buur binnen dezelfde pagina; niets aan de rand
export function moveContentBlock(id: number, pageSlug: PageSlug, direction: "up" | "down") {
const siblings = listContentBlocks(pageSlug);
const index = siblings.findIndex((b) => b.id === id);
const swapWith = direction === "up" ? index - 1 : index + 1;
if (index === -1 || swapWith < 0 || swapWith >= siblings.length) return;
const a = siblings[index];
const b = siblings[swapWith];
const db = getDb();
db.prepare("UPDATE content_blocks SET sort_order = ? WHERE id = ?").run(b.sort_order, a.id);
db.prepare("UPDATE content_blocks SET sort_order = ? WHERE id = ?").run(a.sort_order, b.id);
}
// --- Meertaligheid (EN/DE) ---
// Een taal is "ingevuld" voor een pagina zodra er ≥1 block_translations-rij in
// die taal is. Een vertaling zonder tekst wordt bij opslaan verwijderd, zodat
// dat één simpele query blijft.
// Velden per bloktype die meetellen voor "ingevuld"; afbeeldingen en links tellen niet mee
export const TEXT_FIELDS: Record<BlockType, string[]> = {
hero: ["heading", "text", "buttonLabel"],
richtext: ["heading", "body", "buttonLabel"],
testimonial: ["quote", "attribution"],
benefits: ["heading", "items"],
cta: ["heading", "body", "buttonLabel"],
};
// Leeg = geen zichtbare tekst na strippen van tags en &nbsp; (Quill geeft <p><br></p> voor een leeg veld)
export function isEmptyText(value: unknown): boolean {
if (typeof value === "string") return value.replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim() === "";
if (Array.isArray(value)) return value.every((v) => isEmptyText(v));
return value == null;
}
function hasTranslatedText(type: BlockType, content: Record<string, unknown>): boolean {
return TEXT_FIELDS[type].some((field) => !isEmptyText(content[field]));
}
function parseJsonObject(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
// Voor de editor: { en: { [blockId]: content }, de: { ... } } als plain objects
export function listBlockTranslationsForEditor(slug: PageSlug): Record<string, Record<number, Record<string, unknown>>> {
const rows = getDb()
.prepare(
`SELECT t.block_id, t.lang, t.content
FROM block_translations t JOIN content_blocks b ON b.id = t.block_id
WHERE b.page_slug = ?`
)
.all(slug) as { block_id: number; lang: string; content: string }[];
const out: Record<string, Record<number, Record<string, unknown>>> = {};
for (const lang of LANGS) if (lang !== DEFAULT_LANG) out[lang] = {};
for (const row of rows) {
const parsed = parseJsonObject(row.content);
if (parsed && out[row.lang]) out[row.lang][row.block_id] = parsed;
}
return out;
}
export function upsertBlockTranslation(blockId: number, type: BlockType, lang: Lang, content: Record<string, unknown>) {
if (lang === DEFAULT_LANG) throw new Error("NL staat in content_blocks, niet in block_translations.");
const db = getDb();
if (!hasTranslatedText(type, content)) {
db.prepare("DELETE FROM block_translations WHERE block_id = ? AND lang = ?").run(blockId, lang);
return;
}
db.prepare(
`INSERT INTO block_translations (block_id, lang, content) VALUES (?, ?, ?)
ON CONFLICT (block_id, lang) DO UPDATE SET content = excluded.content`
).run(blockId, lang, JSON.stringify(content));
}
// Ingevulde talen per pagina: { slug: ["en", "de"] } (NL is altijd ingevuld en staat er niet in)
export function getAvailabilityMap(): Record<string, Lang[]> {
const rows = getDb()
.prepare(
`SELECT DISTINCT b.page_slug, t.lang
FROM block_translations t JOIN content_blocks b ON b.id = t.block_id
ORDER BY b.page_slug, t.lang`
)
.all() as { page_slug: string; lang: string }[];
const map: Record<string, Lang[]> = {};
for (const row of rows) {
if (!(LANGS as readonly string[]).includes(row.lang)) continue;
(map[row.page_slug] ??= []).push(row.lang as Lang);
}
return map;
}
function getPageTranslation(slug: PageSlug, lang: Lang): PageTranslation | undefined {
const row = getDb()
.prepare("SELECT meta_title, meta_description, nav_label FROM page_translations WHERE page_slug = ? AND lang = ?")
.get(slug, lang) as PageTranslation | undefined;
return row ? { ...row } : undefined;
}
export function listPageTranslations(slug: PageSlug): Record<string, PageTranslation> {
const out: Record<string, PageTranslation> = {};
for (const lang of LANGS) {
if (lang === DEFAULT_LANG) continue;
const t = getPageTranslation(slug, lang);
if (t) out[lang] = t;
}
return out;
}
export function upsertPageTranslation(slug: PageSlug, lang: Lang, patch: Partial<PageTranslation>) {
if (lang === DEFAULT_LANG) throw new Error("NL staat in pages, niet in page_translations.");
const next = { ...(getPageTranslation(slug, lang) ?? { meta_title: "", meta_description: "", nav_label: "" }), ...patch };
getDb()
.prepare(
`INSERT INTO page_translations (page_slug, lang, meta_title, meta_description, nav_label)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (page_slug, lang) DO UPDATE SET
meta_title = excluded.meta_title,
meta_description = excluded.meta_description,
nav_label = excluded.nav_label`
)
.run(slug, lang, next.meta_title, next.meta_description, next.nav_label);
}
// --- Media ---
const MEDIA_EXTENSIONS = /\.(png|jpe?g|webp|svg)$/i;
const MAX_MEDIA_SIZE = 10 * 1024 * 1024; // 10 MB
export function getMediaDir(): string {
return MEDIA_DIR;
}
// Eerder geüploade afbeeldingen, om opnieuw te kiezen
export function listMediaImages(): string[] {
try {
return readdirSync(MEDIA_DIR)
.filter((f) => MEDIA_EXTENSIONS.test(f))
.sort()
.map((f) => `/media/${f}`);
} catch {
return [];
}
}
export async function saveMediaUpload(file: File): Promise<{ path: string | null; error: string | null }> {
if (file.size === 0) return { path: null, error: null };
if (file.size > MAX_MEDIA_SIZE) return { path: null, error: `${file.name} is groter dan 10 MB.` };
if (!/^image\//.test(file.type) || !MEDIA_EXTENSIONS.test(file.name)) {
return { path: null, error: `${file.name} is geen afbeelding (png, jpg, webp of svg).` };
}
await mkdir(MEDIA_DIR, { recursive: true });
const storedName = `${randomBytes(8).toString("hex")}-${file.name}`.replace(/[^a-zA-Z0-9._-]/g, "_");
await writeFile(path.join(MEDIA_DIR, storedName), Buffer.from(await file.arrayBuffer()));
return { path: `/media/${storedName}`, error: null };
}
+43
View File
@@ -117,6 +117,49 @@ function migrate(db: DatabaseSync) {
addColumnIfMissing(db, "projects", "impactmaker_account_id", "INTEGER");
addColumnIfMissing(db, "projects", "ronde_id", "INTEGER REFERENCES rikx_rondes(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_ronde ON projects(ronde_id)");
// Publieke Website (CMS), overgenomen van rikx-org; functies in lib/cms.ts.
// NL staat in pages/content_blocks zelf, EN/DE in de *_translations-tabellen.
db.exec(`
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
meta_title TEXT NOT NULL DEFAULT '',
meta_description TEXT NOT NULL DEFAULT '',
og_image TEXT,
canonical_url TEXT,
noindex INTEGER NOT NULL DEFAULT 0,
nav_label TEXT,
nav_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS content_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
block_key TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('hero','richtext','testimonial','benefits','cta')),
label TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (page_slug, block_key)
);
CREATE TABLE IF NOT EXISTS page_translations (
page_slug TEXT NOT NULL REFERENCES pages(slug) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en','de')),
meta_title TEXT NOT NULL DEFAULT '',
meta_description TEXT NOT NULL DEFAULT '',
nav_label TEXT NOT NULL DEFAULT '',
PRIMARY KEY (page_slug, lang)
);
CREATE TABLE IF NOT EXISTS block_translations (
block_id INTEGER NOT NULL REFERENCES content_blocks(id) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en','de')),
content TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (block_id, lang)
);
`);
}
function addColumnIfMissing(db: DatabaseSync, table: string, column: string, definition: string) {
+16
View File
@@ -0,0 +1,16 @@
// Talen van de Publieke Website (CMS). Pure helpers zonder Node-imports,
// zodat ook client components ze kunnen gebruiken.
export const LANGS = ["nl", "en", "de"] as const;
export type Lang = (typeof LANGS)[number];
export const DEFAULT_LANG: Lang = "nl";
export const LANG_LABELS: Record<Lang, string> = {
nl: "Nederlands",
en: "English",
de: "Deutsch",
};
export function isLang(value: unknown): value is Lang {
return typeof value === "string" && (LANGS as readonly string[]).includes(value);
}
+17
View File
@@ -0,0 +1,17 @@
// Weergave van een tekstblok (CMS). Losse module zonder node:sqlite-import,
// zodat zowel BlockEditModal (client) als de server action deze lijst gebruikt.
// Ontbreekt de variant in de opgeslagen content, dan geldt "standaard".
export const RICHTEXT_VARIANTS = ["standaard", "oranje-balk", "kaart-grijs", "kaart-oranje"] as const;
export type RichtextVariant = (typeof RICHTEXT_VARIANTS)[number];
export const RICHTEXT_VARIANT_LABELS: Record<RichtextVariant, string> = {
standaard: "Standaard",
"oranje-balk": "Oranje balk (intro)",
"kaart-grijs": "Kaart – grijs",
"kaart-oranje": "Kaart – oranje rand",
};
export function isRichtextVariant(value: unknown): value is RichtextVariant {
return typeof value === "string" && (RICHTEXT_VARIANTS as readonly string[]).includes(value);
}
+8
View File
@@ -0,0 +1,8 @@
import DOMPurify from "isomorphic-dompurify";
// Rich-text (HTML uit de Quill-editor) altijd saniteren vóór opslag.
// `data-list` blijft staan: Quill 2 slaat opsommingen op als
// <ol><li data-list="bullet">; zonder dat attribuut wordt elke opsomming genummerd.
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, { ALLOWED_ATTR: ["href", "target", "rel", "class", "data-list"] });
}