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:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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's van de publieke website.
|
||||
</p>
|
||||
<PagesOverview pages={pages} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 (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 };
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"] });
|
||||
}
|
||||
Generated
+667
-84
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user