"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requireStaff } from "@/lib/admin/guard"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; import { logStaffActivity } from "@/lib/services/staff-activity"; import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; import { presetSettings, settingKey } from "@/lib/theme-settings"; // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; // Extra colour settings beyond the preset palette (buttons + links + gradients). const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"]; const CUSTOM_CSS_MAX = 20000; async function writeSetting(key: string, value: string): Promise { await prisma.websiteSetting.upsert({ where: { key }, update: { value }, create: { key, value, comment: "Theme (housekeeping)" }, }); } export async function saveTheme(formData: FormData): Promise { const staff = await requireStaff(); try { for (const mode of ["light", "dark"] as const) { for (const key of THEME_COLOR_KEYS) { const dbKey = settingKey(key, mode); const raw = String(formData.get(dbKey) ?? "").normalize("NFC").trim(); if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw); } } const radius = String(formData.get("border_radius") ?? "").normalize("NFC").trim(); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); // Typography const font = String(formData.get("font_family") ?? "").normalize("NFC").trim(); if (font in FONTS) await writeSetting("font_family", font); for (const key of HEADING_KEYS) { const v = String(formData.get(key) ?? "").normalize("NFC").trim(); if (/^\d{1,3}$/.test(v)) await writeSetting(key, v); } // Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is). if (formData.has("custom_css")) { const cssRaw = String(formData.get("custom_css") ?? "").normalize("NFC").slice(0, CUSTOM_CSS_MAX); await writeSetting("custom_css", cssRaw); } siteSettings.reload(); await logStaffActivity({ staffId: staff.id, action: "theme_update", description: "Updated theme settings", }); revalidatePath("/", "layout"); } catch { // ignore — page re-renders current state } redirect("/admin/theme?saved=1"); } export async function applyPreset(formData: FormData): Promise { const staff = await requireStaff(); const name = String(formData.get("preset") ?? "").normalize("NFC"); // eslint-disable-next-line security/detect-object-injection -- guarded by null check below const preset = PRESETS[name]; if (!preset) redirect("/admin/theme"); try { for (const [key, value] of presetSettings(preset)) await writeSetting(key, value); await writeSetting("theme_preset", name); siteSettings.reload(); await logStaffActivity({ staffId: staff.id, action: "theme_preset", description: `Applied theme preset "${name}"`, }); revalidatePath("/", "layout"); } catch { // ignore } redirect(`/admin/theme?preset=${encodeURIComponent(name)}`); }