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>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|