diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index bce683dd..3997bb8a 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -7,24 +7,11 @@ 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 EXTRA_COLOR_KEYS = [ - "button_secondary_color", - "button_secondary_text_color", - "button_danger_color", - "button_danger_text_color", - "button_outline_color", - "button_outline_text_color", - "button_secondary_hover_color", - "button_danger_hover_color", - "link_color", - "link_hover_color", - "gradient_from", - "gradient_to", -]; const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"]; const CUSTOM_CSS_MAX = 20000; @@ -40,9 +27,12 @@ export async function saveTheme(formData: FormData): Promise { const staff = await requireStaff(); try { - 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); + 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) ?? "").trim(); + if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw); + } } const radius = String(formData.get("border_radius") ?? "").trim(); if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); @@ -82,7 +72,8 @@ export async function applyPreset(formData: FormData): Promise { if (!preset) redirect("/admin/theme"); try { - for (const [key, value] of Object.entries(preset)) await writeSetting(key, value); + for (const [key, value] of presetSettings(preset)) await writeSetting(key, value); + await writeSetting("theme_preset", name); siteSettings.reload(); await logStaffActivity({ staffId: staff.id, diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index c0dbfd86..f03d8e0d 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -53,7 +53,7 @@ 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) { +function colorGrid(fields: { key: string; label: string; def: string }[], current: Record, suffix = "") { return (
@@ -91,6 +91,15 @@ export default async function AdminTheme({ ALL_COLORS.map(async (f) => [f.key, (await siteSettings.get(f.key, f.def)) ?? f.def] as const), ), ) as Record; + const defaultPreset = PRESETS["Atom (golden)"]; + const currentDark = Object.fromEntries( + await Promise.all( + ALL_COLORS.map(async (f) => [ + f.key, + (await siteSettings.get(`${f.key}_dark`, defaultPreset.dark[f.key as keyof typeof defaultPreset.dark])) ?? defaultPreset.dark[f.key as keyof typeof defaultPreset.dark], + ] 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( @@ -141,7 +150,7 @@ export default async function AdminTheme({ height: 14, borderRadius: 3, // eslint-disable-next-line security/detect-object-injection -- k from hardcoded array - background: palette[k], + background: palette.light[k as keyof typeof palette.light], boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)", marginLeft: -3, }} @@ -156,6 +165,7 @@ export default async function AdminTheme({
+

Light mode

{t("colours")}

{colorGrid(COLOR_FIELDS, current)}
@@ -174,6 +184,20 @@ export default async function AdminTheme({
{colorGrid(GRADIENT_FIELDS, current)}
+

Dark mode

+
+

{t("colours")}

+
{colorGrid(COLOR_FIELDS, currentDark, "_dark")}
+
+
+

{t("buttonsLinks")}

+
{colorGrid(BUTTON_FIELDS, currentDark, "_dark")}
+
+
+

Gradient mix

+
{colorGrid(GRADIENT_FIELDS, currentDark, "_dark")}
+
+

{t("typographyLayout")}

diff --git a/src/lib/theme-settings.test.ts b/src/lib/theme-settings.test.ts new file mode 100644 index 00000000..562dc2d1 --- /dev/null +++ b/src/lib/theme-settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { presetSettings, settingKey } from "@/lib/theme-settings"; +import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; + +describe("theme settings mapping", () => { + it("keeps legacy light keys and suffixes dark keys", () => { + expect(settingKey("color_primary", "light")).toBe("color_primary"); + expect(settingKey("color_primary", "dark")).toBe("color_primary_dark"); + }); + + it("serializes every preset key for both modes without duplicates", () => { + const settings = presetSettings(PRESETS.Ocean); + const keys = settings.map(([key]) => key); + + expect(settings).toHaveLength(THEME_COLOR_KEYS.length * 2); + expect(new Set(keys).size).toBe(keys.length); + for (const key of THEME_COLOR_KEYS) { + expect(keys).toContain(key); + expect(keys).toContain(`${key}_dark`); + } + }); +}); diff --git a/src/lib/theme-settings.ts b/src/lib/theme-settings.ts new file mode 100644 index 00000000..75467dd3 --- /dev/null +++ b/src/lib/theme-settings.ts @@ -0,0 +1,11 @@ +import { THEME_COLOR_KEYS, type ThemeColorKey, type ThemeMode, type ThemePreset } from "@/lib/theme-presets"; + +export function settingKey(key: ThemeColorKey, mode: ThemeMode): string { + return mode === "light" ? key : `${key}_dark`; +} + +export function presetSettings(preset: ThemePreset): Array<[string, string]> { + return (["light", "dark"] as const).flatMap((mode) => + THEME_COLOR_KEYS.map((key) => [settingKey(key, mode), preset[mode][key]] as [string, string]), + ); +}