From 5a4b6f27e9aec3e9efffe4c0d6ca2f7da5d3b749 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 21:14:10 +0200 Subject: [PATCH] Extend the theme editor: typography, button/link colours, custom CSS, more presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grew /admin/theme from colours-only to a full theme editor, all applied live via website_settings + ThemeVars: - Typography: body font (10 web-safe + Google options; Google fonts load via an injected ) and H1/H2/H3 sizes (globals.css now reads --size-heading-* vars). - Buttons & links: secondary/danger button colours + link/link-hover. - Custom CSS: a raw textarea injected after the theme variables (staff- trusted), for anything the controls don't cover. - Presets: 6 → 13 (added Galaxy, Royal, Cyberpunk, Neon, Coffee, Arctic, Christmas). ThemeVars now injects all the new vars + the font link. Verified live (prod, amx_test): saved font=mono / H1=44px / custom CSS → the public home reflected --font-family "Courier New", --size-heading-h1 44px and the injected rule; reverted the test settings. tsc 0, vitest 49/49, next build 0. --- src/actions/admin-theme.ts | 29 +++++- src/app/admin/theme/page.tsx | 165 +++++++++++++++++++++++++--------- src/app/globals.css | 6 +- src/components/theme-vars.tsx | 53 ++++++++++- src/lib/theme-presets.ts | 121 +++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 52 deletions(-) diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 9aeb0ee7bf..555d8c7ee6 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 f586ce078d..a65ec6efde 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)}
+
-
- - -