"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 { 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 { 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 { 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 { 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 = { 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 = { 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, lang: Lang = DEFAULT_LANG ): Promise { 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 = {}; 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 { 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 { 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); }