Files
rikx-beheer/app/website/BlockEditModal.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

271 lines
11 KiB
TypeScript

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