From e6d7f2280bfa7b2623ec76af7a98681e2920092f Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 13 Jul 2026 20:42:40 +0200 Subject: [PATCH] Add named custom theme presets (save/load/rename/delete) in admin theme editor --- src/actions/admin-theme.ts | 77 +++++++++++++ src/app/admin/theme/page.tsx | 85 +++++++++++++- src/components/admin/theme/rename-theme.tsx | 41 +++++++ src/lib/theme-custom-store.ts | 120 ++++++++++++++++++++ 4 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 src/components/admin/theme/rename-theme.tsx create mode 100644 src/lib/theme-custom-store.ts diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index e57bca8f..b0524372 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -8,6 +8,12 @@ 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"; +import { + deleteCustomThemeStore, + getCustomTheme, + snapshotCurrentTheme, + upsertCustomTheme, +} from "@/lib/theme-custom-store"; // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; @@ -92,3 +98,74 @@ export async function applyPreset(formData: FormData): Promise { } redirect(`/admin/theme?preset=${encodeURIComponent(name)}`); } + +export async function saveCustomTheme(formData: FormData): Promise { + const staff = await requireStaff(); + const name = String(formData.get("name") ?? "").normalize("NFC").trim(); + if (!name) redirect("/admin/theme"); + const snapshot = await snapshotCurrentTheme(); + try { + await upsertCustomTheme(name, snapshot); + await logStaffActivity({ + staffId: staff.id, + action: "theme_preset", + description: `Saved custom theme "${name}"`, + }); + revalidatePath("/admin/theme"); + } catch { + // ignore + } + redirect("/admin/theme?savedTheme=1"); +} + +export async function applyCustomTheme(formData: FormData): Promise { + const staff = await requireStaff(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + if (!id) redirect("/admin/theme"); + const theme = await getCustomTheme(id); + if (!theme) redirect("/admin/theme"); + try { + for (const [key, value] of Object.entries(theme.settings)) { + if (value) await writeSetting(key, value); + } + await writeSetting("theme_preset", theme.name); + siteSettings.reload(); + await logStaffActivity({ + staffId: staff.id, + action: "theme_preset", + description: `Applied custom theme "${theme.name}"`, + }); + revalidatePath("/", "layout"); + } catch { + // ignore + } + redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`); +} + +export async function renameCustomTheme(formData: FormData): Promise { + await requireStaff(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + const name = String(formData.get("name") ?? "").normalize("NFC").trim(); + if (!id || !name) redirect("/admin/theme"); + const snapshot = await snapshotCurrentTheme(); + try { + await upsertCustomTheme(name, snapshot, id); + revalidatePath("/admin/theme"); + } catch { + // ignore + } + redirect("/admin/theme?renamed=1"); +} + +export async function deleteCustomTheme(formData: FormData): Promise { + await requireStaff(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + if (!id) redirect("/admin/theme"); + try { + await deleteCustomThemeStore(id); + revalidatePath("/admin/theme"); + } catch { + // ignore + } + redirect("/admin/theme?deletedTheme=1"); +} diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index 5651173a..741fb527 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -1,8 +1,10 @@ import { getTranslations } from "next-intl/server"; -import { applyPreset, saveTheme } from "@/actions/admin-theme"; +import { applyCustomTheme, applyPreset, deleteCustomTheme, saveCustomTheme, saveTheme } from "@/actions/admin-theme"; import { siteSettings } from "@/lib/services/site-settings"; import { FONTS, PRESETS } from "@/lib/theme-presets"; +import { listCustomThemes } from "@/lib/theme-custom-store"; import { ColorField } from "./color-field"; +import { RenameTheme } from "@/components/admin/theme/rename-theme"; export const dynamic = "force-dynamic"; export const metadata = { title: "Theme" }; @@ -106,7 +108,7 @@ function colorFields( export default async function AdminTheme({ searchParams, }: { - searchParams: Promise<{ saved?: string; preset?: string }>; + searchParams: Promise<{ saved?: string; preset?: string; savedTheme?: string; theme?: string; renamed?: string; deletedTheme?: string }>; }) { const t = await getTranslations("pages.admin.theme"); @@ -134,6 +136,7 @@ export default async function AdminTheme({ ), ), ) as Record; + const customThemes = await listCustomThemes(); const radius = (await siteSettings.get("border_radius", "12")) ?? "12"; const fontKey = (await siteSettings.get("font_family", "nunito")) ?? "nunito"; const headings = Object.fromEntries( @@ -163,6 +166,20 @@ export default async function AdminTheme({ {t("presetApplied", { preset: sp.preset })} ) : null} + {sp.savedTheme ? ( +
Theme saved as preset.
+ ) : null} + {sp.theme ? ( +
+ Applied theme “{sp.theme}”. +
+ ) : null} + {sp.renamed ? ( +
Theme renamed.
+ ) : null} + {sp.deletedTheme ? ( +
Theme deleted.
+ ) : null}

{t("presetsTitle")}

@@ -193,6 +210,70 @@ export default async function AdminTheme({
+
+

Your saved themes

+

+ Save the current configuration as a named preset, then load it anytime. Perfect for keeping multiple brand + themes ready to switch. +

+
+ + +
+ + {customThemes.length === 0 ? ( +

No saved themes yet — save one above.

+ ) : ( +
+ {customThemes.map((theme) => ( +
+
+ {["color_primary", "color_accent", "color_surface", "color_text"].map((k) => ( + + ))} +
+
+ {theme.name} + +
+
+
+ + +
+
+ + +
+
+
+ ))} +
+ )} +
+

☀ Light mode

diff --git a/src/components/admin/theme/rename-theme.tsx b/src/components/admin/theme/rename-theme.tsx new file mode 100644 index 00000000..34248109 --- /dev/null +++ b/src/components/admin/theme/rename-theme.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState } from "react"; +import { renameCustomTheme } from "@/actions/admin-theme"; + +export function RenameTheme({ id, name }: { id: string; name: string }) { + const [editing, setEditing] = useState(false); + const [val, setVal] = useState(name); + + if (!editing) { + return ( + + ); + } + + return ( + + + setVal(e.target.value)} + className="w-28 px-1.5 py-0.5 rounded-md border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-xs" + style={{ borderColor: "var(--admin-border)" }} + autoFocus + /> + + +

+ ); +} diff --git a/src/lib/theme-custom-store.ts b/src/lib/theme-custom-store.ts new file mode 100644 index 00000000..94bafae2 --- /dev/null +++ b/src/lib/theme-custom-store.ts @@ -0,0 +1,120 @@ +import { prisma } from "@/lib/prisma"; +import { siteSettings } from "@/lib/services/site-settings"; +import { PRESETS, THEME_COLOR_KEYS, type ThemeColorKey } from "@/lib/theme-presets"; + +export interface CustomTheme { + id: string; + name: string; + createdAt: number; + settings: Record; +} + +const STORE_KEY = "custom_themes"; + +const EXTRA_KEYS = [ + "admin_canvas", + "admin_surface", + "admin_text", + "admin_text_muted", + "admin_border", + "admin_sidebar_bg", + "border_radius", + "font_family", + "size_heading_h1", + "size_heading_h2", + "size_heading_h3", + "custom_css", + "theme_preset", +]; + +const ALL_KEYS: string[] = [ + ...THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]), + ...EXTRA_KEYS, +]; + +function fallbackFor(key: string): string { + const preset = PRESETS["Atom (golden)"]; + if (key.endsWith("_dark")) { + const base = key.slice(0, -("_dark".length)) as ThemeColorKey; + return preset.dark[base] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS + } + if (key in preset.light) { + return preset.light[key as ThemeColorKey] ?? ""; + } + const extras: Record = { + admin_canvas: "#0f172a", + admin_surface: "#1e293b", + admin_text: "#f1f5f9", + admin_text_muted: "#94a3b8", + admin_border: "#334155", + admin_sidebar_bg: "#0f172a", + border_radius: "12", + font_family: "nunito", + size_heading_h1: "30", + size_heading_h2: "22", + size_heading_h3: "18", + custom_css: "", + theme_preset: "Atom (golden)", + }; + return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS +} + +export async function listCustomThemes(): Promise { + const raw = await siteSettings.get(STORE_KEY, "[]"); + try { + const parsed = JSON.parse(raw ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed as CustomTheme[]; + } catch { + return []; + } +} + +export async function snapshotCurrentTheme(): Promise> { + const entries = await Promise.all( + ALL_KEYS.map(async (key) => [key, (await siteSettings.get(key, null)) ?? fallbackFor(key)] as const), + ); + return Object.fromEntries(entries); +} + +async function persist(themes: CustomTheme[]): Promise { + const value = JSON.stringify(themes); + await prisma.websiteSetting.upsert({ + where: { key: STORE_KEY }, + create: { key: STORE_KEY, value, comment: "Theme (housekeeping)" }, + update: { value }, + }); + siteSettings.reload(); +} + +export async function upsertCustomTheme(name: string, settings: Record, id?: string): Promise { + const themes = await listCustomThemes(); + const trimmed = name.trim() || "Untitled theme"; + if (id) { + const idx = themes.findIndex((t) => t.id === id); + if (idx >= 0) { + themes[idx] = { ...themes[idx], name: trimmed, settings }; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array + await persist(themes); + return themes[idx]; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array + } + } + const theme: CustomTheme = { + id: globalThis.crypto.randomUUID(), + name: trimmed, + createdAt: Date.now(), + settings, + }; + themes.push(theme); + await persist(themes); + return theme; +} + +export async function getCustomTheme(id: string): Promise { + const themes = await listCustomThemes(); + return themes.find((t) => t.id === id) ?? null; +} + +export async function deleteCustomThemeStore(id: string): Promise { + const themes = await listCustomThemes(); + await persist(themes.filter((t) => t.id !== id)); +}