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 = { 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


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): boolean { return TEXT_FIELDS[type].some((field) => !isEmptyText(content[field])); } function parseJsonObject(text: string): Record | null { try { const parsed = JSON.parse(text); return parsed && typeof parsed === "object" ? (parsed as Record) : null; } catch { return null; } } // Voor de editor: { en: { [blockId]: content }, de: { ... } } als plain objects export function listBlockTranslationsForEditor(slug: PageSlug): Record>> { 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>> = {}; 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) { 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 { 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 = {}; 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 { const out: Record = {}; 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) { 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 }; }