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:
@@ -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";
|
||||
Reference in New Issue
Block a user