Files
rikx-beheer/app/website/PageEditor.tsx
T
remco-vermeer 9be692974e 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>
2026-10-08 15:27:12 +02:00

453 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}