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
+5 -2
View File
@@ -12,10 +12,12 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i
- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SendGrid of SMTP; zonder beide toont de pagina de link om zelf door te geven
- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database)
- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet
- `/website` (Publieke Website → Pagina's) — CMS, overgenomen van rikx-org (`/portaal/paginas`) maar daar los van (eigen tabellen in deze database): pagina's aanmaken/verwijderen, hoofdmenu (naam + volgorde met pijltjes), SEO (meta-titel/-omschrijving, canonical, og:image, noindex) en per pagina content-blokken (`hero`, `richtext` met weergave standaard/oranje balk/kaart, `testimonial`, `benefits`, `cta`) toevoegen, bewerken (Quill), herordenen en verwijderen. NL/EN/DE: NL is de basis, EN/DE per veld met terugval op NL; een taal is "ingevuld" zodra ≥1 blok vertaald is. Er zijn hier geen vaste pagina's en (nog) geen publieke weergave, sitemap of bezoekteller. Logica in `lib/cms.ts`, acties in `app/website/actions.ts`; rich text wordt vóór opslag gesaniteerd (`lib/sanitize.ts`)
- `/media/[filename]` — openbare route voor via het CMS geüploade afbeeldingen (png/jpg/webp/svg, max 10 MB) uit `MEDIA_DIR`; met CSP-sandbox zodat een SVG geen script kan uitvoeren op het beheerdomein
- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie
- Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort")
- Huisstijlkleuren als Tailwind-tokens in `app/globals.css`: `rikx-orange`, `rikx-purple` (actief menu-item), `rikx-maroon` (primaire knop). Statuskleuren in `lib/projectStatus.ts`
- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`)
- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login` en de mediaroute)
## Stack
- Next.js 16 + TypeScript + Tailwind CSS
@@ -42,7 +44,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
- Locatie lokaal: `./data/app.db`
- Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start)
- Schema: `lib/db.ts` — migraties draaien automatisch bij eerste verbinding
- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10)
- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `pages`, `content_blocks`, `page_translations`, `block_translations` (CMS, zie `/website`), `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10)
- Rekenmethode (Rikx Waardeberekening Arbeid): `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg; achtergrond in `_doc/Waarderingssystematiek Rikx juni 2023.pdf`. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten)
- De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*`
- Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform
@@ -59,6 +61,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
|-----------|-----------|--------|
| `SESSION_SECRET` | — | HMAC-secret voor sessie-cookies — per omgeving uniek genereren |
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
| `MEDIA_DIR` | `<map van DB_PATH>/media` | Geüploade CMS-afbeeldingen (productie: `/data/media`, op hetzelfde volume) |
| `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) |
| `NODE_ENV` | `development` | In productie: `production` |
| `SENDGRID_API_KEY` | — | E-mail via SendGrid (uitnodigingen voor experts); zelfde key als het Rikx ZRM-platform. Nooit committen. Leeg én geen SMTP = geen e-mail, link wordt in beeld getoond |
+5 -1
View File
@@ -26,7 +26,11 @@ const NAV_ITEMS: NavItem[] = [
section: "crm",
items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Experts", href: "/experts" }],
},
{ label: "Publieke Website", section: "website" },
{
label: "Publieke Website",
section: "website",
items: [{ label: "Pagina's", href: "/website" }],
},
];
function Caret() {
+42
View File
@@ -0,0 +1,42 @@
import { readFile } from "fs/promises";
import path from "path";
import { getMediaDir } from "../../../lib/cms";
const CONTENT_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
};
// Openbaar (geen sessie): afbeeldingen voor de publieke website, via het CMS
// geüpload naar het /data-volume. De CSP-sandbox voorkomt dat een SVG met
// script iets kan op dit domein, waar ook het beheer draait.
export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) {
const { filename } = await params;
// Alleen een kale bestandsnaam (geen path traversal)
if (filename.includes("/") || filename.includes("\\") || filename.includes("..")) {
return new Response("Ongeldige bestandsnaam", { status: 400 });
}
const contentType = CONTENT_TYPES[path.extname(filename).toLowerCase()];
if (!contentType) return new Response("Niet ondersteund bestandstype", { status: 400 });
let buffer: Buffer;
try {
buffer = await readFile(path.join(getMediaDir(), filename));
} catch {
return new Response("Niet gevonden", { status: 404 });
}
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
},
});
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import "quill/dist/quill.snow.css";
import { useEffect, useRef, useState } from "react";
import type Quill from "quill";
import type { BenefitsContent, ContentBlock, CtaContent, HeroContent, PageSlug, RichtextContent, TestimonialContent } from "../../lib/cms";
import { DEFAULT_LANG, LANG_LABELS, type Lang } from "../../lib/i18n";
import { RICHTEXT_VARIANTS, RICHTEXT_VARIANT_LABELS, type RichtextVariant } from "../../lib/richtextVariants";
import { updateBlockAction } from "./actions";
import { ImageField } from "./ImageField";
import { Modal } from "./Modal";
import { errorClass, hintClass, inputClass, labelClass, primaryButton, secondaryButton, textareaClass } from "./ui";
function useRichText(initialHtml: string) {
const editorRef = useRef<HTMLDivElement | null>(null);
const quillRef = useRef<Quill | null>(null);
useEffect(() => {
let cancelled = false;
import("quill").then(({ default: Quill }) => {
if (cancelled || !editorRef.current || quillRef.current) return;
const quill = new Quill(editorRef.current, {
theme: "snow",
modules: {
toolbar: [["bold", "italic", "underline"], [{ list: "ordered" }, { list: "bullet" }], ["link"], ["clean"]],
},
});
if (initialHtml) quill.clipboard.dangerouslyPasteHTML(initialHtml);
quillRef.current = quill;
});
return () => {
cancelled = true;
quillRef.current = null;
};
}, []);
const getHtml = () => quillRef.current?.root.innerHTML ?? initialHtml;
const setHtml = (html: string) => {
if (quillRef.current) quillRef.current.setContents(quillRef.current.clipboard.convert({ html }));
};
return { editorRef, getHtml, setHtml };
}
function RichTextField({ label, editorRef }: { label: string; editorRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="flex flex-col gap-1">
<span className={labelClass}>{label}</span>
<div className="rounded-b-lg bg-white text-zinc-900 [&_.ql-editor]:min-h-24">
<div ref={editorRef} />
</div>
</div>
);
}
function TextInput({ label, value, onChange, required }: { label: string; value: string; onChange: (v: string) => void; required?: boolean }) {
return (
<label className="flex flex-col gap-1">
<span className={labelClass}>{label}</span>
<input value={value} onChange={(e) => onChange(e.target.value)} required={required} className={inputClass} />
</label>
);
}
type AnyContent = HeroContent & RichtextContent & TestimonialContent & BenefitsContent & CtaContent;
export function BlockEditModal({
block,
slug,
lang,
translation,
mediaImages,
onClose,
onSaved,
}: {
block: ContentBlock;
slug: PageSlug;
lang: Lang;
// EN/DE: de bestaande vertaling van dit blok (of undefined)
translation?: Record<string, unknown>;
mediaImages: string[];
onClose: () => void;
onSaved: () => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isNl = lang === DEFAULT_LANG;
const nlContent = JSON.parse(block.content) as AnyContent;
// EN/DE opent leeg (niet voorgevuld met NL), anders telt een ongewijzigde kopie als "vertaald"
const initial = (isNl ? nlContent : (translation ?? {})) as AnyContent;
const [image, setImage] = useState(initial.image ?? "");
const [buttonLabel, setButtonLabel] = useState(initial.buttonLabel ?? "");
const [buttonHref, setButtonHref] = useState(initial.buttonHref ?? "");
const [attribution, setAttribution] = useState(initial.attribution ?? "");
const [plainHeading, setPlainHeading] = useState(initial.heading ?? "");
const [itemsText, setItemsText] = useState((initial.items ?? []).join("\n"));
const [variant, setVariant] = useState<RichtextVariant>(nlContent.variant ?? "standaard");
const variantEditable = isNl && block.type === "richtext";
const heading = useRichText(block.type === "hero" || block.type === "richtext" ? (initial.heading ?? "") : "");
const text = useRichText(block.type === "hero" ? (initial.text ?? "") : "");
const body = useRichText(block.type === "richtext" || block.type === "cta" ? (initial.body ?? "") : "");
const quote = useRichText(block.type === "testimonial" ? (initial.quote ?? "") : "");
const imageEmptyLabel = isNl ? "Geen afbeelding" : "Leeg = NL-afbeelding gebruiken";
// Nederlandse tekstvelden als startpunt om te vertalen (afbeeldingen/links blijven leeg = NL)
function copyFromNl() {
if (nlContent.buttonLabel !== undefined) setButtonLabel(nlContent.buttonLabel ?? "");
if (nlContent.attribution !== undefined) setAttribution(nlContent.attribution ?? "");
if (block.type === "benefits") {
setPlainHeading(nlContent.heading ?? "");
setItemsText((nlContent.items ?? []).join("\n"));
}
if (block.type === "cta") {
setPlainHeading(nlContent.heading ?? "");
body.setHtml(nlContent.body ?? "");
}
if (block.type === "hero") {
heading.setHtml(nlContent.heading ?? "");
text.setHtml(nlContent.text ?? "");
}
if (block.type === "richtext") {
heading.setHtml(nlContent.heading ?? "");
body.setHtml(nlContent.body ?? "");
}
if (block.type === "testimonial") quote.setHtml(nlContent.quote ?? "");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
let content: Record<string, unknown>;
switch (block.type) {
case "hero":
content = { heading: heading.getHtml(), text: text.getHtml(), image, buttonLabel, buttonHref };
break;
case "richtext":
content = { heading: heading.getHtml(), body: body.getHtml(), buttonLabel, buttonHref };
if (variantEditable) content.variant = variant;
break;
case "testimonial":
content = { quote: quote.getHtml(), attribution, image };
break;
case "benefits":
content = { heading: plainHeading, items: itemsText.split("\n").map((s) => s.trim()).filter(Boolean) };
break;
case "cta":
content = { heading: plainHeading, body: body.getHtml(), buttonLabel, buttonHref };
break;
}
const result = await updateBlockAction(block.id, slug, content, lang);
if (result.error) setError(result.error);
else {
onSaved();
onClose();
}
} finally {
setSaving(false);
}
}
const buttonFields = (required: boolean) => (
<>
<TextInput label={required ? "Knoptekst" : "Knoptekst (optioneel)"} value={buttonLabel} onChange={setButtonLabel} required={required && isNl} />
<label className="flex flex-col gap-1">
<span className={labelClass}>{required ? "Knoplink" : "Knoplink (optioneel)"}</span>
<input value={buttonHref} onChange={(e) => setButtonHref(e.target.value)} required={required && isNl} className={inputClass} />
<span className={hintClass}>Begint met /, #, https://, mailto: of tel:{isNl ? "" : " — leeg = NL-link"}</span>
</label>
</>
);
return (
<Modal title={block.label} onClose={onClose}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{!isNl && (
<div className="rounded-lg bg-orange-50 dark:bg-zinc-800 border border-orange-200 dark:border-zinc-700 p-4 text-sm text-zinc-700 dark:text-zinc-300 flex flex-col gap-2">
<p>
Je vertaalt naar <strong>{LANG_LABELS[lang]}</strong>. Leeg gelaten velden vallen terug op het Nederlands.
</p>
<div>
<button type="button" className={secondaryButton} onClick={copyFromNl}>
Kopieer NL als startpunt
</button>
</div>
<details>
<summary className="cursor-pointer font-medium">Nederlandse tekst (referentie)</summary>
<div className="mt-2 flex flex-col gap-1 text-zinc-600 dark:text-zinc-400">
{[nlContent.heading, nlContent.text, nlContent.body, nlContent.quote, nlContent.attribution]
.concat(nlContent.items ?? [])
.filter((v): v is string => typeof v === "string" && v.trim() !== "")
.map((v, i) => (
<p key={i}>{v.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()}</p>
))}
</div>
</details>
</div>
)}
{block.type === "hero" && (
<>
<RichTextField label="Kop" editorRef={heading.editorRef} />
<RichTextField label="Tekst" editorRef={text.editorRef} />
<ImageField label="Afbeelding" value={image} onChange={setImage} mediaImages={mediaImages} emptyLabel={imageEmptyLabel} />
{buttonFields(false)}
</>
)}
{block.type === "richtext" && (
<>
{variantEditable && (
<label className="flex flex-col gap-1">
<span className={labelClass}>Weergave</span>
<select value={variant} onChange={(e) => setVariant(e.target.value as RichtextVariant)} className={inputClass}>
{RICHTEXT_VARIANTS.map((v) => (
<option key={v} value={v}>
{RICHTEXT_VARIANT_LABELS[v]}
</option>
))}
</select>
<span className={hintClass}>Kaarten die direct na elkaar staan, komen naast elkaar (3 per rij). Geldt voor alle talen.</span>
</label>
)}
<RichTextField label="Kop" editorRef={heading.editorRef} />
<RichTextField label="Tekst" editorRef={body.editorRef} />
{buttonFields(false)}
</>
)}
{block.type === "testimonial" && (
<>
<RichTextField label="Citaat" editorRef={quote.editorRef} />
<TextInput label="Naam en functie" value={attribution} onChange={setAttribution} required={isNl} />
<ImageField label="Portretfoto" value={image} onChange={setImage} mediaImages={mediaImages} emptyLabel={imageEmptyLabel} />
</>
)}
{block.type === "benefits" && (
<>
<TextInput label="Kop" value={plainHeading} onChange={setPlainHeading} required={isNl} />
<label className="flex flex-col gap-1">
<span className={labelClass}>Punten (één per regel)</span>
<textarea value={itemsText} onChange={(e) => setItemsText(e.target.value)} rows={8} className={textareaClass} />
</label>
</>
)}
{block.type === "cta" && (
<>
<TextInput label="Kop" value={plainHeading} onChange={setPlainHeading} required={isNl} />
<RichTextField label="Tekst" editorRef={body.editorRef} />
{buttonFields(true)}
</>
)}
{error && <p className={errorClass}>{error}</p>}
<div className="flex gap-3">
<button type="submit" className={primaryButton} disabled={saving}>
{saving ? "Bezig..." : "Opslaan"}
</button>
<button type="button" className={secondaryButton} onClick={onClose} disabled={saving}>
Annuleren
</button>
</div>
</form>
</Modal>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useId, useRef, useState } from "react";
import { uploadMediaAction } from "./actions";
import { errorClass, inputClass, labelClass, secondaryButton } from "./ui";
// Kies een eerder geüploade afbeelding of upload een nieuwe (max 10 MB)
export function ImageField({
label,
value,
onChange,
mediaImages,
emptyLabel = "Geen afbeelding",
}: {
label: string;
value: string;
onChange: (path: string) => void;
mediaImages: string[];
emptyLabel?: string;
}) {
const [uploaded, setUploaded] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const fieldId = useId();
async function handleUpload() {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploading(true);
setError(null);
try {
const result = await uploadMediaAction(formData);
if (result.error) setError(result.error);
else if (result.path) {
setUploaded((u) => [result.path!, ...u]);
onChange(result.path);
if (fileInputRef.current) fileInputRef.current.value = "";
}
} finally {
setUploading(false);
}
}
// Ook een gekozen afbeelding die niet (meer) in de lijst staat blijft zichtbaar als optie
const options = [...new Set([...(value ? [value] : []), ...uploaded, ...mediaImages])];
return (
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor={fieldId}>
{label}
</label>
<select id={fieldId} value={value} onChange={(e) => onChange(e.target.value)} className={inputClass}>
<option value="">{emptyLabel}</option>
{options.map((img) => (
<option key={img} value={img}>
{img.split("/").pop()}
</option>
))}
</select>
{value && (
<img src={value} alt="" className="max-w-[200px] max-h-[120px] object-cover rounded-lg border border-zinc-200 dark:border-zinc-700" />
)}
<div className="flex flex-wrap items-center gap-3">
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="text-sm text-zinc-600 dark:text-zinc-400" />
<button type="button" className={secondaryButton} onClick={handleUpload} disabled={uploading}>
{uploading ? "Bezig..." : "Uploaden"}
</button>
</div>
{error && <p className={errorClass}>{error}</p>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-10" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-2xl rounded-xl bg-white dark:bg-zinc-900 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-zinc-200 dark:border-zinc-800 px-6 py-4">
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
<button
type="button"
aria-label="Sluiten"
onClick={onClose}
className="text-2xl leading-none text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200"
>
&times;
</button>
</div>
<div className="px-6 py-5">{children}</div>
</div>
</div>
);
}
+452
View File
@@ -0,0 +1,452 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { BlockType, ContentBlock, Page, PageSlug, PageTranslation } from "../../lib/cms";
import { DEFAULT_LANG, LANGS, LANG_LABELS, type Lang } from "../../lib/i18n";
import { RICHTEXT_VARIANT_LABELS, isRichtextVariant } from "../../lib/richtextVariants";
import {
createContentBlockAction,
deleteContentBlockAction,
moveContentBlockAction,
updatePageNavAction,
updatePageSeoAction,
} from "./actions";
import { BlockEditModal } from "./BlockEditModal";
import { ImageField } from "./ImageField";
import { Modal } from "./Modal";
import { LangDot } from "./PagesOverview";
import {
cardClass,
errorClass,
hintClass,
inputClass,
labelClass,
primaryButton,
savedClass,
secondaryButton,
textareaClass,
} from "./ui";
export const TYPE_LABELS: Record<BlockType, string> = {
hero: "Hero",
richtext: "Tekstblok",
testimonial: "Testimonial",
benefits: "Voordelenlijst",
cta: "Call to action",
};
const BLOCK_TYPE_OPTIONS: BlockType[] = ["hero", "richtext", "testimonial", "benefits", "cta"];
type BlockTranslations = Record<string, Record<number, Record<string, unknown>>>;
// "Tekstblok · Kaart – grijs": de gekozen weergave zichtbaar zonder het blok te openen
function variantSuffix(block: ContentBlock): string {
if (block.type !== "richtext") return "";
try {
const variant = (JSON.parse(block.content) as { variant?: unknown }).variant;
return isRichtextVariant(variant) && variant !== "standaard" ? ` · ${RICHTEXT_VARIANT_LABELS[variant]}` : "";
} catch {
return "";
}
}
export function PageEditor({
slug,
page,
blocks,
mediaImages,
pageTranslations,
blockTranslations,
}: {
slug: PageSlug;
page: Page;
blocks: ContentBlock[];
mediaImages: string[];
pageTranslations: Record<string, PageTranslation>;
blockTranslations: BlockTranslations;
}) {
const router = useRouter();
const [lang, setLang] = useState<Lang>(DEFAULT_LANG);
const [editingBlock, setEditingBlock] = useState<ContentBlock | null>(null);
const [addBlockOpen, setAddBlockOpen] = useState(false);
const [busyBlockId, setBusyBlockId] = useState<number | null>(null);
const isNl = lang === DEFAULT_LANG;
// Een taal is "ingevuld" zodra er ≥1 vertaald blok is
const translatedCount = (l: Lang) => blocks.filter((b) => blockTranslations[l]?.[b.id]).length;
async function handleDeleteBlock(block: ContentBlock) {
if (!confirm(`Blok "${block.label}" verwijderen, met de vertalingen?`)) return;
setBusyBlockId(block.id);
try {
const result = await deleteContentBlockAction(block.id, slug);
if (result.error) alert(result.error);
else router.refresh();
} finally {
setBusyBlockId(null);
}
}
async function handleMoveBlock(block: ContentBlock, direction: "up" | "down") {
setBusyBlockId(block.id);
try {
await moveContentBlockAction(block.id, slug, direction);
router.refresh();
} finally {
setBusyBlockId(null);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<div role="tablist" aria-label="Taal" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800">
{LANGS.map((l) => (
<button
key={l}
type="button"
role="tab"
aria-selected={l === lang}
onClick={() => setLang(l)}
className={`-mb-px flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
l === lang
? "border-rikx-orange text-zinc-900 dark:text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200"
}`}
>
{LANG_LABELS[l]}
{l !== DEFAULT_LANG && <LangDot filled={translatedCount(l) > 0} />}
</button>
))}
</div>
{!isNl && (
<p className="text-sm text-zinc-500 mt-3">
{LANG_LABELS[lang]}: {translatedCount(lang)} van {blocks.length} blokken vertaald.
{translatedCount(lang) === 0
? " De taal telt als ingevuld zodra minstens één blok is vertaald."
: " Niet-vertaalde velden vallen terug op het Nederlands."}
</p>
)}
</div>
<NavForm
key={`nav-${lang}`}
slug={slug}
lang={lang}
nlLabel={page.nav_label ?? ""}
initialLabel={isNl ? (page.nav_label ?? "") : (pageTranslations[lang]?.nav_label ?? "")}
/>
<SeoForm key={`seo-${lang}`} slug={slug} lang={lang} page={page} translation={pageTranslations[lang]} mediaImages={mediaImages} />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
Inhoud{isNl ? "" : ` (${lang.toUpperCase()})`}
</h2>
{isNl && (
<button type="button" className={primaryButton} onClick={() => setAddBlockOpen(true)}>
+ Blok toevoegen
</button>
)}
</div>
{blocks.length === 0 && <p className="text-sm text-zinc-500">Nog geen inhoud — voeg een blok toe.</p>}
{blocks.map((block, index) => {
const translated = Boolean(blockTranslations[lang]?.[block.id]);
const busy = busyBlockId === block.id;
return (
<div key={block.id} className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 px-5 py-4 flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-48">
<div className="text-xs uppercase tracking-wide text-zinc-500 flex items-center gap-2">
{TYPE_LABELS[block.type]}
{variantSuffix(block)}
{!isNl && (
<span
className={`normal-case tracking-normal rounded-full px-2 py-0.5 ${
translated ? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800"
}`}
>
{translated ? "Vertaald" : "Niet vertaald"}
</span>
)}
</div>
<div className="font-medium text-zinc-900 dark:text-zinc-100">{block.label}</div>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className={secondaryButton} onClick={() => setEditingBlock(block)}>
{isNl ? "Bewerken" : translated ? "Vertaling bewerken" : "Vertalen"}
</button>
{isNl && (
<>
<button
type="button"
aria-label="Omhoog"
className={secondaryButton}
disabled={busy || index === 0}
onClick={() => handleMoveBlock(block, "up")}
>
↑
</button>
<button
type="button"
aria-label="Omlaag"
className={secondaryButton}
disabled={busy || index === blocks.length - 1}
onClick={() => handleMoveBlock(block, "down")}
>
↓
</button>
<button
type="button"
className="px-2 text-sm text-red-500 hover:text-red-700 dark:hover:text-red-400 disabled:opacity-40"
disabled={busy}
onClick={() => handleDeleteBlock(block)}
>
Verwijderen
</button>
</>
)}
</div>
</div>
);
})}
</section>
{editingBlock && (
<BlockEditModal
key={`${editingBlock.id}-${lang}`}
block={editingBlock}
slug={slug}
lang={lang}
translation={blockTranslations[lang]?.[editingBlock.id]}
mediaImages={mediaImages}
onClose={() => setEditingBlock(null)}
onSaved={() => router.refresh()}
/>
)}
{addBlockOpen && <AddBlockModal slug={slug} onClose={() => setAddBlockOpen(false)} onCreated={() => router.refresh()} />}
</div>
);
}
// Apart component met key={lang}, zodat de beginwaarde bij een taalwissel opnieuw wordt gezet
function NavForm({ slug, lang, nlLabel, initialLabel }: { slug: PageSlug; lang: Lang; nlLabel: string; initialLabel: string }) {
const router = useRouter();
const [navLabel, setNavLabel] = useState(initialLabel);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const isNl = lang === DEFAULT_LANG;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
try {
const result = await updatePageNavAction(slug, navLabel, lang);
if (result.error) setError(result.error);
else {
setSaved(true);
router.refresh();
}
} finally {
setSaving(false);
}
}
return (
<section className={cardClass}>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Navigatie</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={labelClass}>
{isNl ? "Naam in het hoofdmenu" : `Naam in het menu (${LANG_LABELS[lang]})`}
</span>
<input
value={navLabel}
onChange={(e) => setNavLabel(e.target.value)}
placeholder={isNl ? "Bijv. Nieuwsbrief" : nlLabel || "Bijv. Newsletter"}
className={inputClass}
/>
<span className={hintClass}>
{isNl
? "Leeg = niet in het menu (de pagina blijft bereikbaar). De volgorde wijzig je in het overzicht."
: "Leeg = Nederlandse naam. Of en waar de pagina in het menu staat, bepaal je op het Nederlandse tabblad."}
</span>
</label>
{error && <p className={errorClass}>{error}</p>}
{saved && <p className={savedClass}>Opgeslagen.</p>}
<div>
<button type="submit" className={primaryButton} disabled={saving}>
{saving ? "Bezig..." : "Navigatie opslaan"}
</button>
</div>
</form>
</section>
);
}
function SeoForm({
slug,
lang,
page,
translation,
mediaImages,
}: {
slug: PageSlug;
lang: Lang;
page: Page;
translation?: PageTranslation;
mediaImages: string[];
}) {
const router = useRouter();
const isNl = lang === DEFAULT_LANG;
const [metaTitle, setMetaTitle] = useState(isNl ? page.meta_title : (translation?.meta_title ?? ""));
const [metaDescription, setMetaDescription] = useState(isNl ? page.meta_description : (translation?.meta_description ?? ""));
const [ogImage, setOgImage] = useState(page.og_image ?? "");
const [canonicalUrl, setCanonicalUrl] = useState(page.canonical_url ?? "");
const [noindex, setNoindex] = useState(Boolean(page.noindex));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
try {
const result = await updatePageSeoAction(slug, metaTitle, metaDescription, ogImage, canonicalUrl, noindex, lang);
if (result.error) setError(result.error);
else {
setSaved(true);
router.refresh();
}
} finally {
setSaving(false);
}
}
return (
<section className={cardClass}>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-4">SEO / Google-optimalisatie</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className={labelClass}>
Meta-titel{isNl ? <span className="text-red-500 ml-1">*</span> : " (leeg = Nederlandse titel)"}
</span>
<input
value={metaTitle}
onChange={(e) => setMetaTitle(e.target.value)}
placeholder={isNl ? undefined : page.meta_title}
required={isNl}
className={inputClass}
/>
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>Meta-omschrijving{isNl ? "" : " (leeg = Nederlandse omschrijving)"}</span>
<textarea
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
placeholder={isNl ? undefined : page.meta_description}
rows={3}
className={textareaClass}
/>
</label>
{isNl ? (
<>
<label className="flex flex-col gap-1">
<span className={labelClass}>Canonieke URL (optioneel, alleen Nederlands)</span>
<input
value={canonicalUrl}
onChange={(e) => setCanonicalUrl(e.target.value)}
placeholder="https://..."
className={inputClass}
/>
</label>
<ImageField
label="Social share-afbeelding (og:image, optioneel — alle talen)"
value={ogImage}
onChange={setOgImage}
mediaImages={mediaImages}
/>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" checked={noindex} onChange={(e) => setNoindex(e.target.checked)} className="accent-rikx-orange" />
Uitsluiten van zoekmachines (noindex, alle talen)
</label>
</>
) : (
<p className={hintClass}>
Canonieke URL, social share-afbeelding en noindex gelden voor alle talen en beheer je op het Nederlandse tabblad.
</p>
)}
{error && <p className={errorClass}>{error}</p>}
{saved && <p className={savedClass}>Opgeslagen.</p>}
<div>
<button type="submit" className={primaryButton} disabled={saving}>
{saving ? "Bezig..." : "SEO opslaan"}
</button>
</div>
</form>
</section>
);
}
function AddBlockModal({ slug, onClose, onCreated }: { slug: PageSlug; onClose: () => void; onCreated: () => void }) {
const [type, setType] = useState<BlockType>("richtext");
const [label, setLabel] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const result = await createContentBlockAction(slug, type, label, `block-${Date.now().toString(36)}`);
if (result.error) setError(result.error);
else {
onCreated();
onClose();
}
} finally {
setSaving(false);
}
}
return (
<Modal title="Blok toevoegen" onClose={onClose}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className={labelClass}>Type</span>
<select value={type} onChange={(e) => setType(e.target.value as BlockType)} className={inputClass}>
{BLOCK_TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{TYPE_LABELS[t]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>Naam (voor in het overzicht)</span>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Bijv. Introductie" required autoFocus className={inputClass} />
</label>
{error && <p className={errorClass}>{error}</p>}
<div className="flex gap-3">
<button type="submit" className={primaryButton} disabled={saving}>
{saving ? "Bezig..." : "Toevoegen"}
</button>
<button type="button" className={secondaryButton} onClick={onClose} disabled={saving}>
Annuleren
</button>
</div>
</form>
</Modal>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { Page } from "../../lib/cms";
import { DEFAULT_LANG, LANGS, LANG_LABELS, type Lang } from "../../lib/i18n";
import { createPageAction, deletePageAction, movePageNavAction } from "./actions";
import { Modal } from "./Modal";
import { errorClass, hintClass, inputClass, labelClass, primaryButton, secondaryButton } from "./ui";
type PageRow = Page & { langs: Lang[] };
export function LangDot({ filled }: { filled: boolean }) {
return <span className={`inline-block w-2 h-2 rounded-full ${filled ? "bg-green-600" : "bg-zinc-300 dark:bg-zinc-600"}`} />;
}
export function PagesOverview({ pages }: { pages: PageRow[] }) {
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [busySlug, setBusySlug] = useState<string | null>(null);
const navPages = pages.filter((p) => p.nav_label).sort((a, b) => a.nav_order - b.nav_order || a.slug.localeCompare(b.slug));
async function handleDelete(slug: string) {
if (!confirm(`Pagina "${slug}" definitief verwijderen, met alle blokken en vertalingen?`)) return;
setBusySlug(slug);
try {
const result = await deletePageAction(slug);
if (result.error) alert(result.error);
else router.refresh();
} finally {
setBusySlug(null);
}
}
async function handleMove(slug: string, direction: "up" | "down") {
setBusySlug(slug);
try {
await movePageNavAction(slug, direction);
router.refresh();
} finally {
setBusySlug(null);
}
}
return (
<div className="flex flex-col gap-6">
<div>
<button type="button" className={primaryButton} onClick={() => setCreateOpen(true)}>
+ Nieuwe pagina
</button>
</div>
{pages.length === 0 ? (
<p className="text-sm text-zinc-500">Nog geen pagina&apos;s aangemaakt.</p>
) : (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-zinc-50 dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400">
<tr>
<th className="text-left px-4 py-3 font-medium">Pagina</th>
<th className="text-left px-4 py-3 font-medium">Meta-titel</th>
<th className="text-left px-4 py-3 font-medium">In menu</th>
<th className="text-left px-4 py-3 font-medium">Talen</th>
<th className="px-4 py-3" colSpan={2}></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{pages.map((p) => {
const navIndex = navPages.findIndex((np) => np.slug === p.slug);
const busy = busySlug === p.slug;
return (
<tr key={p.slug} className="bg-white dark:bg-zinc-950">
<td className="px-4 py-3">
<Link href={`/website/${p.slug}`} className="font-medium text-zinc-900 dark:text-zinc-100 hover:text-rikx-orange-dark">
{p.nav_label || p.meta_title || p.slug}
</Link>
<div className="text-xs text-zinc-500">/{p.slug}</div>
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">{p.meta_title || "—"}</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
{p.nav_label ? (
<span className="inline-flex items-center gap-1.5">
<span className="tabular-nums text-zinc-400">{navIndex + 1}.</span>
{p.nav_label}
<button
type="button"
className="px-1 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 disabled:opacity-30"
disabled={busy || navIndex <= 0}
onClick={() => handleMove(p.slug, "up")}
aria-label="Omhoog in menu"
>
↑
</button>
<button
type="button"
className="px-1 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 disabled:opacity-30"
disabled={busy || navIndex === navPages.length - 1}
onClick={() => handleMove(p.slug, "down")}
aria-label="Omlaag in menu"
>
↓
</button>
</span>
) : (
<span className="text-zinc-400">Niet in menu</span>
)}
</td>
<td className="px-4 py-3">
<span className="inline-flex gap-3 text-xs text-zinc-600 dark:text-zinc-400">
{LANGS.map((l) => {
const filled = l === DEFAULT_LANG || p.langs.includes(l);
return (
<span
key={l}
className="inline-flex items-center gap-1"
title={`${LANG_LABELS[l]}: ${filled ? "ingevuld" : "niet ingevuld"}`}
>
<LangDot filled={filled} />
{l.toUpperCase()}
</span>
);
})}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/website/${p.slug}`}
className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
>
Bewerken
</Link>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors disabled:opacity-40"
disabled={busy}
onClick={() => handleDelete(p.slug)}
>
Verwijderen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{createOpen && <CreatePageModal onClose={() => setCreateOpen(false)} onCreated={(slug) => router.push(`/website/${slug}`)} />}
</div>
);
}
function CreatePageModal({ onClose, onCreated }: { onClose: () => void; onCreated: (slug: string) => void }) {
const [slug, setSlug] = useState("");
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const result = await createPageAction(slug, title);
if (result.error) setError(result.error);
else if (result.slug) onCreated(result.slug);
} finally {
setSaving(false);
}
}
return (
<Modal title="Nieuwe pagina" onClose={onClose}>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className={labelClass}>Slug</span>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="nieuwsbrief"
pattern="[a-z0-9\-]+"
required
autoFocus
className={inputClass}
/>
<span className={hintClass}>Het deel van de URL, alleen kleine letters, cijfers en koppeltekens.</span>
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>Titel</span>
<input value={title} onChange={(e) => setTitle(e.target.value)} required className={inputClass} />
<span className={hintClass}>Wordt de meta-titel en de naam in het menu; beide later aan te passen.</span>
</label>
{error && <p className={errorClass}>{error}</p>}
<div className="flex gap-3">
<button type="submit" className={primaryButton} disabled={saving}>
{saving ? "Bezig..." : "Aanmaken"}
</button>
<button type="button" className={secondaryButton} onClick={onClose} disabled={saving}>
Annuleren
</button>
</div>
</form>
</Modal>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { requireAdmin } from "../../../lib/auth";
import {
getPage,
listBlockTranslationsForEditor,
listContentBlocks,
listMediaImages,
listPageTranslations,
} from "../../../lib/cms";
import { AppHeader } from "../../AppHeader";
import { PageEditor } from "../PageEditor";
export default async function WebsitePageEditor({ params }: { params: Promise<{ slug: string }> }) {
const session = await requireAdmin();
const { slug } = await params;
const page = getPage(slug);
if (!page) notFound();
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="website" />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
<Link href="/website" className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100">
← Pagina&apos;s
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mt-2">
{page.nav_label || page.meta_title || slug}
</h1>
<p className="text-sm text-zinc-500 mt-1 mb-6">/{slug}</p>
<PageEditor
slug={slug}
page={page}
blocks={listContentBlocks(slug)}
mediaImages={listMediaImages()}
pageTranslations={listPageTranslations(slug)}
blockTranslations={listBlockTranslationsForEditor(slug)}
/>
</main>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "../../lib/auth";
import {
BLOCK_TYPES,
RESERVED_SLUGS,
createContentBlock,
createPage,
deleteContentBlock,
deletePage,
getContentBlock,
getPage,
listContentBlocks,
moveContentBlock,
movePageNav,
saveMediaUpload,
updateBlockContent,
updatePageNavLabel,
updatePageSeo,
upsertBlockTranslation,
upsertPageTranslation,
type BlockType,
type PageSlug,
} from "../../lib/cms";
import { DEFAULT_LANG, isLang, type Lang } from "../../lib/i18n";
import { isRichtextVariant } from "../../lib/richtextVariants";
import { sanitizeHtml } from "../../lib/sanitize";
type Result = { error: string | null };
function revalidateWebsite(slug?: PageSlug) {
revalidatePath("/website");
if (slug) revalidatePath(`/website/${slug}`);
}
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const BLOCK_KEY_PATTERN = /^[a-z0-9_-]+$/;
export async function createPageAction(slug: string, title: string): Promise<Result & { slug?: string }> {
await requireAdmin();
const cleanSlug = slug.trim().toLowerCase();
const cleanTitle = title.trim();
if (!cleanSlug || !cleanTitle) return { error: "Vul een slug en een titel in." };
if (!SLUG_PATTERN.test(cleanSlug)) return { error: "Slug mag alleen kleine letters, cijfers en koppeltekens bevatten." };
if (RESERVED_SLUGS.includes(cleanSlug)) return { error: "Deze slug is gereserveerd. Kies een andere." };
if (getPage(cleanSlug)) return { error: "Er bestaat al een pagina met deze slug." };
createPage(cleanSlug, cleanTitle, cleanTitle);
revalidateWebsite();
return { error: null, slug: cleanSlug };
}
export async function deletePageAction(slug: PageSlug): Promise<Result> {
await requireAdmin();
if (!getPage(slug)) return { error: "Onbekende pagina." };
deletePage(slug);
revalidateWebsite();
return { error: null };
}
export async function movePageNavAction(slug: PageSlug, direction: "up" | "down") {
await requireAdmin();
movePageNav(slug, direction === "up" ? "up" : "down");
revalidateWebsite();
}
export async function updatePageNavAction(slug: PageSlug, navLabel: string, lang: Lang = DEFAULT_LANG): Promise<Result> {
await requireAdmin();
if (!isLang(lang)) return { error: "Onbekende taal." };
if (!getPage(slug)) return { error: "Onbekende pagina." };
// EN/DE: alleen het vertaalde label; of en waar de pagina in het menu staat, bepaalt NL
if (lang !== DEFAULT_LANG) upsertPageTranslation(slug, lang, { nav_label: navLabel.trim() });
else updatePageNavLabel(slug, navLabel.trim() || null);
revalidateWebsite(slug);
return { error: null };
}
export async function updatePageSeoAction(
slug: PageSlug,
metaTitle: string,
metaDescription: string,
ogImage: string,
canonicalUrl: string,
noindex: boolean,
lang: Lang = DEFAULT_LANG
): Promise<Result> {
await requireAdmin();
if (!isLang(lang)) return { error: "Onbekende taal." };
if (!getPage(slug)) return { error: "Onbekende pagina." };
if (lang !== DEFAULT_LANG) {
// EN/DE: alleen titel en omschrijving (leeg = NL); og:image, canonical en noindex gelden voor alle talen
upsertPageTranslation(slug, lang, { meta_title: metaTitle.trim(), meta_description: metaDescription.trim() });
revalidateWebsite(slug);
return { error: null };
}
if (!metaTitle.trim()) return { error: "Meta-titel is verplicht." };
const canonical = canonicalUrl.trim();
if (canonical && !/^https?:\/\//i.test(canonical)) return { error: "Canonieke URL moet met http(s):// beginnen." };
updatePageSeo(slug, metaTitle.trim(), metaDescription.trim(), ogImage.trim() || null, canonical || null, Boolean(noindex));
revalidateWebsite(slug);
return { error: null };
}
// Velden per bloktype die opgeslagen mogen worden; de rest wordt genegeerd
const BLOCK_FIELDS: Record<BlockType, string[]> = {
hero: ["heading", "text", "image", "buttonLabel", "buttonHref"],
richtext: ["heading", "body", "buttonLabel", "buttonHref"],
testimonial: ["quote", "attribution", "image"],
benefits: ["heading", "items"],
cta: ["heading", "body", "buttonLabel", "buttonHref"],
};
// Rich-text-velden (HTML) worden gesaniteerd vóór opslag
const HTML_FIELDS: Record<BlockType, string[]> = {
hero: ["heading", "text"],
richtext: ["heading", "body"],
testimonial: ["quote"],
benefits: ["heading"],
cta: ["heading", "body"],
};
const SAFE_HREF = /^(\/(?!\/)|#|https?:\/\/|mailto:|tel:)/i;
const SAFE_IMAGE = /^(\/(?!\/)|https?:\/\/)/i;
export async function updateBlockAction(
blockId: number,
slug: PageSlug,
content: Record<string, unknown>,
lang: Lang = DEFAULT_LANG
): Promise<Result> {
await requireAdmin();
if (!isLang(lang)) return { error: "Onbekende taal." };
// Type en pagina komen uit de database, niet van de client
const block = getContentBlock(blockId);
if (!block || block.page_slug !== slug) return { error: "Blok niet gevonden op deze pagina." };
const clean: Record<string, unknown> = {};
for (const field of BLOCK_FIELDS[block.type]) {
const value = content[field];
if (field === "items") {
if (Array.isArray(value)) {
clean.items = value
.filter((item): item is string => typeof item === "string")
.map((item) => sanitizeHtml(item))
.filter((item) => item.trim().length > 0);
}
} else if (typeof value === "string") {
clean[field] = HTML_FIELDS[block.type].includes(field) ? sanitizeHtml(value) : value.trim();
}
}
if (typeof clean.buttonHref === "string" && clean.buttonHref && !SAFE_HREF.test(clean.buttonHref)) {
return { error: "Knoplink moet beginnen met /, #, http(s)://, mailto: of tel:." };
}
if (typeof clean.image === "string" && clean.image && !SAFE_IMAGE.test(clean.image)) {
return { error: "Ongeldige afbeelding." };
}
// Weergave van een tekstblok: alleen via NL, geldt voor alle talen
if (block.type === "richtext" && lang === DEFAULT_LANG && isRichtextVariant(content.variant) && content.variant !== "standaard") {
clean.variant = content.variant;
}
if (lang === DEFAULT_LANG) updateBlockContent(blockId, clean);
// EN/DE: een vertaling zonder tekst wordt verwijderd, zodat "ingevuld" altijd echte tekst betekent
else upsertBlockTranslation(blockId, block.type, lang, clean);
revalidateWebsite(slug);
return { error: null };
}
export async function createContentBlockAction(
slug: PageSlug,
type: BlockType,
label: string,
blockKey: string
): Promise<Result> {
await requireAdmin();
if (!getPage(slug)) return { error: "Onbekende pagina." };
if (!BLOCK_TYPES.includes(type)) return { error: "Onbekend bloktype." };
const cleanLabel = label.trim();
const cleanKey = blockKey.trim().toLowerCase();
if (!cleanLabel) return { error: "Vul een naam voor het blok in." };
if (!BLOCK_KEY_PATTERN.test(cleanKey)) return { error: "Blok-sleutel mag alleen kleine letters, cijfers, - en _ bevatten." };
if (listContentBlocks(slug).some((b) => b.block_key === cleanKey)) {
return { error: "Er bestaat al een blok met deze sleutel op deze pagina." };
}
createContentBlock(slug, cleanKey, type, cleanLabel);
revalidateWebsite(slug);
return { error: null };
}
export async function deleteContentBlockAction(blockId: number, slug: PageSlug): Promise<Result> {
await requireAdmin();
const block = getContentBlock(blockId);
if (!block || block.page_slug !== slug) return { error: "Blok niet gevonden op deze pagina." };
deleteContentBlock(blockId);
revalidateWebsite(slug);
return { error: null };
}
export async function moveContentBlockAction(blockId: number, slug: PageSlug, direction: "up" | "down") {
await requireAdmin();
moveContentBlock(blockId, slug, direction === "up" ? "up" : "down");
revalidateWebsite(slug);
}
export async function uploadMediaAction(formData: FormData): Promise<{ path: string | null; error: string | null }> {
await requireAdmin();
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) return { path: null, error: "Geen bestand geselecteerd." };
return saveMediaUpload(file);
}
+24
View File
@@ -0,0 +1,24 @@
import { requireAdmin } from "../../lib/auth";
import { getAvailabilityMap, listPages } from "../../lib/cms";
import { AppHeader } from "../AppHeader";
import { PagesOverview } from "./PagesOverview";
export default async function WebsitePage() {
const session = await requireAdmin();
const availability = getAvailabilityMap();
const pages = listPages().map((p) => ({ ...p, langs: availability[p.slug] ?? [] }));
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="website" />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Pagina&apos;s ({pages.length})</h1>
<p className="text-sm text-zinc-500 mt-1 mb-6">
Beheer de inhoud, het hoofdmenu en de SEO-instellingen van de pagina&apos;s van de publieke website.
</p>
<PagesOverview pages={pages} />
</main>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
// Gedeelde Tailwind-klassen voor de CMS-schermen (Publieke Website)
export const inputClass =
"h-10 w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
export const textareaClass =
"w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
export const labelClass = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
export const hintClass = "text-xs text-zinc-500";
export const cardClass = "bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6";
export const primaryButton =
"rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50";
export const secondaryButton =
"rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors disabled:opacity-40";
export const errorClass = "text-sm text-red-600 dark:text-red-400";
export const savedClass = "text-sm text-green-700 dark:text-green-400";
+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"] });
}
+667 -84
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,8 +10,10 @@
},
"dependencies": {
"@types/nodemailer": "^8.0.1",
"isomorphic-dompurify": "^2.36.0",
"next": "16.3.4",
"nodemailer": "^10.0.3",
"quill": "^2.0.3",
"react": "19.2.8",
"react-dom": "19.2.8",
"tsx": "^4.23.13"