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

210 lines
8.8 KiB
TypeScript

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