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