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

221 lines
8.0 KiB
TypeScript

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