feat: persist complete multitheme palettes
This commit is contained in:
1 parent
3fd2a27719
commit
9cdd0b4000
4 files changed
+69
-21
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user