From 119917688329f7b4428d29e3ca5c8986cdd99dcb Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 28 Jun 2026 20:20:38 +0200 Subject: [PATCH] Add Theme Settings tool + polish remaining radio detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /admin/theme: recolour the whole site from housekeeping. 6 atom- faithful presets (Atom/Midnight/Ocean/Forest/Sunset/Candy) + per-colour pickers for the 12 settings ThemeVars injects + border radius. Writes to website_settings, busts the siteSettings cache, and revalidates the layout so the new palette applies live with no rebuild. Constants live in src/lib/theme-presets.ts (a "use server" file can't export objects). Added to the admin sidebar (System). - radio/contests/[id] + giveaways/[id] wrapped in ContentCard to match the public design system. - Skipped a separate VPN page: /admin/ip already manages the IP white/blacklist, so it would only duplicate it. Verified live (prod, amx_test): applied the Ocean preset → home renders --color-primary #0ea5e9 site-wide; reverted the test rows. tsc 0, vitest 49/49, next build 0. --- src/actions/admin-theme.ts | 65 ++++++++++++ src/app/admin/layout.tsx | 1 + src/app/admin/theme/page.tsx | 138 ++++++++++++++++++++++++++ src/app/radio/contests/[id]/page.tsx | 10 +- src/app/radio/giveaways/[id]/page.tsx | 10 +- src/lib/theme-presets.ts | 108 ++++++++++++++++++++ 6 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 src/actions/admin-theme.ts create mode 100644 src/app/admin/theme/page.tsx create mode 100644 src/lib/theme-presets.ts diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts new file mode 100644 index 00000000..9aeb0ee7 --- /dev/null +++ b/src/actions/admin-theme.ts @@ -0,0 +1,65 @@ +"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 { 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%-]+$/; + +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 key of THEME_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); + + siteSettings.reload(); + await logStaffActivity({ + staffId: staff.id, + action: "theme_update", + description: "Updated theme colours", + }); + 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") ?? ""); + const preset = PRESETS[name]; + if (!preset) redirect("/admin/theme"); + + try { + for (const [key, value] of Object.entries(preset)) await writeSetting(key, value); + 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)}`); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 0491e588..574d6bf2 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -69,6 +69,7 @@ const NAV_GROUPS: { label: string; items: { href: string; label: string }[] }[] { href: "/admin/housekeeping", label: "Housekeeping" }, { href: "/admin/rooms", label: "Rooms" }, { href: "/admin/email-templates", label: "Email" }, + { href: "/admin/theme", label: "Theme" }, { href: "/admin/settings", label: "Settings" }, ], }, diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx new file mode 100644 index 00000000..f586ce07 --- /dev/null +++ b/src/app/admin/theme/page.tsx @@ -0,0 +1,138 @@ +import { applyPreset, saveTheme } from "@/actions/admin-theme"; +import { siteSettings } from "@/lib/services/site-settings"; +import { PRESETS } from "@/lib/theme-presets"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Theme" }; + +const 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" }, + { key: "color_background", label: "Background", def: "#f8fafc" }, + { key: "color_surface", label: "Surface / cards", def: "#ffffff" }, + { key: "color_dropdown", label: "Dropdown", def: "#ffffff" }, + { key: "color_navbar", label: "Navbar", def: "#ffffff" }, + { 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" }, + { key: "button_primary_color", label: "Button", def: "#f59e0b" }, + { key: "button_text_color", label: "Button text", def: "#1e293b" }, +]; + +/** 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; +} + +export default async function AdminTheme({ + searchParams, +}: { + searchParams: Promise<{ saved?: string; preset?: string }>; +}) { + const sp = await searchParams; + + const current = Object.fromEntries( + await Promise.all( + FIELDS.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"; + + return ( +
+

Theme

+

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

+ + {sp.saved ? ( +

+ Theme saved +

+ ) : null} + {sp.preset ? ( +

+ Applied preset “{sp.preset}” +

+ ) : null} + +
+

Presets

+
+ {Object.entries(PRESETS).map(([name, palette]) => ( +
+ + +
+ ))} +
+
+ +
+

Custom colours

+
+
+ {FIELDS.map((f) => ( + + ))} +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/src/app/radio/contests/[id]/page.tsx b/src/app/radio/contests/[id]/page.tsx index 58d3bc56..01cd0433 100644 --- a/src/app/radio/contests/[id]/page.tsx +++ b/src/app/radio/contests/[id]/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { ContentCard } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -33,13 +34,12 @@ export default async function RadioContestDetailPage({ const contestIdStr = contest.id.toString(); return ( -
-

+

+

← Back to contests

-
-

Contest #{contestIdStr}

+ @@ -52,7 +52,7 @@ export default async function RadioContestDetailPage({
-
+
); } diff --git a/src/app/radio/giveaways/[id]/page.tsx b/src/app/radio/giveaways/[id]/page.tsx index 85afa235..846c2a34 100644 --- a/src/app/radio/giveaways/[id]/page.tsx +++ b/src/app/radio/giveaways/[id]/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; +import { ContentCard } from "@/components/public/ui"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -33,13 +34,12 @@ export default async function RadioGiveawayDetailPage({ const giveawayIdStr = giveaway.id.toString(); return ( -
-

+

+

← Back to giveaways

-
-

Giveaway #{giveawayIdStr}

+ @@ -52,7 +52,7 @@ export default async function RadioGiveawayDetailPage({
-
+
); } diff --git a/src/lib/theme-presets.ts b/src/lib/theme-presets.ts new file mode 100644 index 00000000..1900aece --- /dev/null +++ b/src/lib/theme-presets.ts @@ -0,0 +1,108 @@ +// Theme constants shared by the admin Theme page and its server action. Kept in +// a plain module because a "use server" file may only export async functions. + +// The colour settings ThemeVars injects into :root. Editing these recolours the +// whole site live (ThemeVars reads them on every request). +export const THEME_COLOR_KEYS = [ + "color_primary", + "color_background", + "color_surface", + "color_dropdown", + "color_navbar", + "color_navbar_text", + "color_text", + "color_text_muted", + "color_accent", + "button_primary_color", + "button_text_color", + "border_color", +] as const; + +// Curated palettes (faithful to the atom theme presets). Each is a full set of +// the colour keys above so applying one fully reskins the site. +export const PRESETS: Record> = { + "Atom (golden)": { + color_primary: "#f59e0b", + color_background: "#f8fafc", + color_surface: "#ffffff", + color_dropdown: "#ffffff", + color_navbar: "#ffffff", + color_navbar_text: "#1e293b", + color_text: "#0f172a", + color_text_muted: "#64748b", + color_accent: "#10b981", + button_primary_color: "#f59e0b", + button_text_color: "#1e293b", + border_color: "#eeb425", + }, + Midnight: { + color_primary: "#eeb425", + color_background: "#0f1117", + color_surface: "#171a21", + color_dropdown: "#1f242d", + color_navbar: "#171a21", + color_navbar_text: "#e5e7eb", + color_text: "#e5e7eb", + color_text_muted: "#9ca3af", + color_accent: "#22c55e", + button_primary_color: "#eeb425", + button_text_color: "#1a1a2e", + border_color: "#eeb425", + }, + Ocean: { + color_primary: "#0ea5e9", + color_background: "#f0f9ff", + color_surface: "#ffffff", + color_dropdown: "#ffffff", + color_navbar: "#ffffff", + color_navbar_text: "#0c4a6e", + color_text: "#082f49", + color_text_muted: "#64748b", + color_accent: "#14b8a6", + button_primary_color: "#0ea5e9", + button_text_color: "#ffffff", + border_color: "#38bdf8", + }, + Forest: { + color_primary: "#16a34a", + color_background: "#f3faf4", + color_surface: "#ffffff", + color_dropdown: "#ffffff", + color_navbar: "#ffffff", + color_navbar_text: "#14532d", + color_text: "#052e16", + color_text_muted: "#5f6b63", + color_accent: "#84cc16", + button_primary_color: "#16a34a", + button_text_color: "#ffffff", + border_color: "#22c55e", + }, + Sunset: { + color_primary: "#f97316", + color_background: "#fff7ed", + color_surface: "#ffffff", + color_dropdown: "#ffffff", + color_navbar: "#ffffff", + color_navbar_text: "#7c2d12", + color_text: "#431407", + color_text_muted: "#78716c", + color_accent: "#ec4899", + button_primary_color: "#f97316", + button_text_color: "#ffffff", + border_color: "#fb923c", + }, + Candy: { + color_primary: "#ec4899", + color_background: "#fdf2f8", + color_surface: "#ffffff", + color_dropdown: "#ffffff", + color_navbar: "#ffffff", + color_navbar_text: "#831843", + color_text: "#500724", + color_text_muted: "#9d6b80", + color_accent: "#a855f7", + button_primary_color: "#ec4899", + button_text_color: "#ffffff", + border_color: "#f472b6", + }, +};