9be692974e
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>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
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 (Quill geeft <p><br></p> voor een leeg veld)
|
|
export function isEmptyText(value: unknown): boolean {
|
|
if (typeof value === "string") return value.replace(/<[^>]*>/g, "").replace(/ /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 };
|
|
}
|