Add named custom theme presets (save/load/rename/delete) in admin theme editor
Local Build and Deploy / deploy (push) Successful in 58s
Local Build and Deploy / deploy (push) Successful in 58s
This commit is contained in:
1 parent
01b70c2369
commit
e6d7f2280b
4 files changed
+321
-2
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user