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 { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
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).
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
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)}`);
|
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 { 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 { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { FONTS, PRESETS } from "@/lib/theme-presets";
|
import { FONTS, PRESETS } from "@/lib/theme-presets";
|
||||||
|
import { listCustomThemes } from "@/lib/theme-custom-store";
|
||||||
import { ColorField } from "./color-field";
|
import { ColorField } from "./color-field";
|
||||||
|
import { RenameTheme } from "@/components/admin/theme/rename-theme";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const metadata = { title: "Theme" };
|
export const metadata = { title: "Theme" };
|
||||||
@@ -106,7 +108,7 @@ function colorFields(
|
|||||||
export default async function AdminTheme({
|
export default async function AdminTheme({
|
||||||
searchParams,
|
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");
|
const t = await getTranslations("pages.admin.theme");
|
||||||
|
|
||||||
@@ -134,6 +136,7 @@ export default async function AdminTheme({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
) as Record<string, string>;
|
) as Record<string, string>;
|
||||||
|
const customThemes = await listCustomThemes();
|
||||||
const radius = (await siteSettings.get("border_radius", "12")) ?? "12";
|
const radius = (await siteSettings.get("border_radius", "12")) ?? "12";
|
||||||
const fontKey = (await siteSettings.get("font_family", "nunito")) ?? "nunito";
|
const fontKey = (await siteSettings.get("font_family", "nunito")) ?? "nunito";
|
||||||
const headings = Object.fromEntries(
|
const headings = Object.fromEntries(
|
||||||
@@ -163,6 +166,20 @@ export default async function AdminTheme({
|
|||||||
{t("presetApplied", { preset: sp.preset })}
|
{t("presetApplied", { preset: sp.preset })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
|
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
|
||||||
@@ -193,6 +210,70 @@ export default async function AdminTheme({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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}>
|
<form action={saveTheme}>
|
||||||
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
|
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
<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