feat: persist complete multitheme palettes

This commit is contained in:
Simo committed 2026-07-12 14:49:36 +02:00
1 parent 3fd2a27719
commit 9cdd0b4000
4 files changed
+69 -21

No files matched your search

+9 -18
View File
@@ -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<void> {
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<void> {
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,
+27 -3
View File
@@ -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<string, string>) {
function colorGrid(fields: { key: string; label: string; def: string }[], current: Record<string, string>, suffix = "") {
return (
<div
style={{
@@ -66,7 +66,7 @@ function colorGrid(fields: { key: string; label: string; def: string }[], curren
<label key={f.key} style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<input
type="color"
name={f.key}
name={`${f.key}${suffix}`}
defaultValue={toHex(current[f.key], f.def)}
style={{ width: 40, height: 30, padding: 0, border: "none", background: "none" }}
/>
@@ -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<string, string>;
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<string, string>;
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({
</section>
<form action={saveTheme}>
<h2 className="mt-6 text-lg font-extrabold">Light mode</h2>
<section className="mt-6">
<h2 className="admin-section-title">{t("colours")}</h2>
<div className="admin-card">{colorGrid(COLOR_FIELDS, current)}</div>
@@ -174,6 +184,20 @@ export default async function AdminTheme({
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, current)}</div>
</section>
<h2 className="mt-8 text-lg font-extrabold">Dark mode</h2>
<section className="mt-4">
<h3 className="admin-section-title">{t("colours")}</h3>
<div className="admin-card">{colorGrid(COLOR_FIELDS, currentDark, "_dark")}</div>
</section>
<section className="mt-6">
<h3 className="admin-section-title">{t("buttonsLinks")}</h3>
<div className="admin-card">{colorGrid(BUTTON_FIELDS, currentDark, "_dark")}</div>
</section>
<section className="mt-6">
<h3 className="admin-section-title">Gradient mix</h3>
<div className="admin-card">{colorGrid(GRADIENT_FIELDS, currentDark, "_dark")}</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">{t("typographyLayout")}</h2>
<div className="admin-card">
+22
View File
@@ -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`);
}
});
});
+11
View File
@@ -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]),
);
}