diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 9aeb0ee7..555d8c7e 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -6,10 +6,19 @@ 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 { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; +import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; // 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). +const EXTRA_COLOR_KEYS = [ + "button_secondary_color", + "button_danger_color", + "link_color", + "link_hover_color", +]; +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({ @@ -23,18 +32,32 @@ export async function saveTheme(formData: FormData): Promise { const staff = await requireStaff(); try { - for (const key of THEME_COLOR_KEYS) { + for (const key of [...THEME_COLOR_KEYS, ...EXTRA_COLOR_KEYS]) { const raw = String(formData.get(key) ?? "").trim(); if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw); } const radius = String(formData.get("border_radius") ?? "").trim(); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); + // Typography + const font = String(formData.get("font_family") ?? "").trim(); + if (font in FONTS) await writeSetting("font_family", font); + for (const key of HEADING_KEYS) { + const v = String(formData.get(key) ?? "").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") ?? "").slice(0, CUSTOM_CSS_MAX); + await writeSetting("custom_css", cssRaw); + } + siteSettings.reload(); await logStaffActivity({ staffId: staff.id, action: "theme_update", - description: "Updated theme colours", + description: "Updated theme settings", }); revalidatePath("/", "layout"); } catch { diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index f586ce07..a65ec6ef 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -1,11 +1,11 @@ import { applyPreset, saveTheme } from "@/actions/admin-theme"; import { siteSettings } from "@/lib/services/site-settings"; -import { PRESETS } from "@/lib/theme-presets"; +import { FONTS, PRESETS } from "@/lib/theme-presets"; export const dynamic = "force-dynamic"; export const metadata = { title: "Theme" }; -const FIELDS: { key: string; label: string; def: string }[] = [ +const COLOR_FIELDS: { key: string; label: string; def: string }[] = [ { key: "color_primary", label: "Primary", def: "#f59e0b" }, { key: "color_accent", label: "Accent", def: "#10b981" }, { key: "border_color", label: "Border (golden)", def: "#eeb425" }, @@ -16,15 +16,57 @@ const FIELDS: { key: string; label: string; def: string }[] = [ { key: "color_navbar_text", label: "Navbar text", def: "#1e293b" }, { key: "color_text", label: "Text", def: "#0f172a" }, { key: "color_text_muted", label: "Muted text", def: "#64748b" }, +]; + +const BUTTON_FIELDS: { key: string; label: string; def: string }[] = [ { key: "button_primary_color", label: "Button", def: "#f59e0b" }, { key: "button_text_color", label: "Button text", def: "#1e293b" }, + { key: "button_secondary_color", label: "Secondary", def: "#22c55e" }, + { key: "button_danger_color", label: "Danger", def: "#ef4444" }, + { key: "link_color", label: "Link", def: "#eeb425" }, + { key: "link_hover_color", label: "Link hover", def: "#cf9d15" }, ]; +const HEADINGS: { key: string; label: string; def: string }[] = [ + { key: "size_heading_h1", label: "H1", def: "30" }, + { key: "size_heading_h2", label: "H2", def: "22" }, + { key: "size_heading_h3", label: "H3", def: "18" }, +]; + +const ALL_COLORS = [...COLOR_FIELDS, ...BUTTON_FIELDS]; + /** A needs #rrggbb — fall back to the default otherwise. */ function toHex(value: string | null, def: string): string { return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : def; } +function colorGrid( + fields: { key: string; label: string; def: string }[], + current: Record, +) { + return ( +
+ {fields.map((f) => ( + + ))} +
+ ); +} + export default async function AdminTheme({ searchParams, }: { @@ -34,16 +76,23 @@ export default async function AdminTheme({ const current = Object.fromEntries( await Promise.all( - FIELDS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const), + ALL_COLORS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const), ), ) as Record; const radius = (await siteSettings.get("border_radius", "12")) ?? "12"; + const fontKey = (await siteSettings.get("font_family", "nunito")) ?? "nunito"; + const headings = Object.fromEntries( + await Promise.all( + HEADINGS.map(async (h) => [h.key, (await siteSettings.get(h.key, h.def)) ?? h.def] as const), + ), + ) as Record; + const customCss = (await siteSettings.get("custom_css", "")) ?? ""; return (

Theme

- Recolour the whole site. Values are written to website_settings and applied + Restyle the whole site. Values are written to website_settings and applied live (no rebuild).

@@ -91,48 +140,80 @@ export default async function AdminTheme({ -
-

Custom colours

-
-
- {FIELDS.map((f) => ( - - ))} -
+ +
+

Colours

+
{colorGrid(COLOR_FIELDS, current)}
+
-
- - -