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
+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>
);
}