Add named custom theme presets (save/load/rename/delete) in admin theme editor
Local Build and Deploy / deploy (push) Successful in 58s

This commit is contained in:
openhands committed 2026-07-13 20:42:40 +02:00
1 parent 01b70c2369
commit e6d7f2280b
4 files changed
+321 -2

No files matched your search

+77
View File
@@ -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<void> {
}
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
}
export async function saveCustomTheme(formData: FormData): Promise<void> {
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<void> {
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<void> {
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<void> {
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");
}
+83 -2
View File
@@ -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<string, string>;
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 })}
</div>
) : null}
{sp.savedTheme ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Theme saved as preset.</div>
) : null}
{sp.theme ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
Applied theme “{sp.theme}”.
</div>
) : null}
{sp.renamed ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Theme renamed.</div>
) : null}
{sp.deletedTheme ? (
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Theme deleted.</div>
) : null}
<section className="mt-6">
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
@@ -193,6 +210,70 @@ export default async function AdminTheme({
</div>
</section>
<section className="mt-6">
<h2 className="admin-section-title">Your saved themes</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
Save the current configuration as a named preset, then load it anytime. Perfect for keeping multiple brand
themes ready to switch.
</p>
<form action={saveCustomTheme} className="flex flex-wrap items-center gap-2 mb-4">
<input
type="text"
name="name"
placeholder="Theme name (e.g. Summer Gold)"
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-[var(--admin-text)] text-sm w-64"
style={{ borderColor: "var(--admin-border)" }}
/>
<button type="submit" className="btn btn-primary">
Save current theme
</button>
</form>
{customThemes.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)]">No saved themes yet — save one above.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{customThemes.map((theme) => (
<div key={theme.id} className="admin-card p-3 flex flex-col gap-2">
<div className="flex items-center gap-1">
{["color_primary", "color_accent", "color_surface", "color_text"].map((k) => (
<span
key={k}
style={{
width: 20,
height: 20,
borderRadius: 4,
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
background: theme.settings[k] ?? "#64748b",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-[var(--admin-text)] truncate">{theme.name}</span>
<RenameTheme id={theme.id} name={theme.name} />
</div>
<div className="flex gap-2">
<form action={applyCustomTheme}>
<input type="hidden" name="id" value={theme.id} />
<button type="submit" className="btn btn-sm btn-outline">
Load
</button>
</form>
<form action={deleteCustomTheme}>
<input type="hidden" name="id" value={theme.id} />
<button type="submit" className="btn btn-sm btn-danger">
Delete
</button>
</form>
</div>
</div>
))}
</div>
)}
</section>
<form action={saveTheme}>
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
@@ -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 (
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] no-underline"
>
Rename
</button>
);
}
return (
<form action={renameCustomTheme} className="flex items-center gap-1">
<input type="hidden" name="id" value={id} />
<input
name="name"
value={val}
onChange={(e) => 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
/>
<button type="submit" className="text-xs font-semibold text-[var(--admin-accent)]">
OK
</button>
<button type="button" onClick={() => setEditing(false)} className="text-xs text-[var(--admin-text-muted)]">
✕
</button>
</form>
);
}
+120
View File
@@ -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<string, string>;
}
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<string, string> = {
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<CustomTheme[]> {
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<Record<string, string>> {
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<void> {
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<string, string>, id?: string): Promise<CustomTheme> {
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<CustomTheme | null> {
const themes = await listCustomThemes();
return themes.find((t) => t.id === id) ?? null;
}
export async function deleteCustomThemeStore(id: string): Promise<void> {
const themes = await listCustomThemes();
await persist(themes.filter((t) => t.id !== id));
}