9be692974e
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>
271 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|