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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user