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>
221 lines
8.0 KiB
TypeScript
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);
|
|
}
|