feat(housekeeping): add per-hotel theme manager with import/export and background

Theme Manager under /admin-next/hotel/theme-manager lets the owner save, apply, rename, delete, import, and export custom themes, plus set a custom site background by URL or upload. Themes are stored in WebsiteSetting/custom_themes JSON so they survive CMS updates.
This commit is contained in:
openhands committed 2026-09-16 19:45:57 +02:00
1 parent 55c13b533c
commit 5b4b275b2a
13 files changed
+1376 -1

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
// Site background settings shared by the Theme Manager, its server actions,
// and ThemeVars. Kept in a plain module because it is imported by client
// components and "use server" files.
export const BACKGROUND_KEYS = [
"background_image",
"background_position",
"background_size",
"background_repeat",
] as const;
export type BackgroundKey = (typeof BACKGROUND_KEYS)[number];
export const BACKGROUND_POSITIONS = [
"center center",
"top center",
"top left",
"top right",
"center left",
"center right",
"bottom center",
"bottom left",
"bottom right",
] as const;
export const BACKGROUND_SIZES = ["cover", "contain", "auto"] as const;
export const BACKGROUND_REPEATS = [
"no-repeat",
"repeat",
"repeat-x",
"repeat-y",
] as const;
export const BACKGROUND_DEFAULTS: Record<BackgroundKey, string> = {
background_image: "",
background_position: "center center",
background_size: "cover",
background_repeat: "no-repeat",
};
/**
* A URL accepted as a custom site background. Blocks `javascript:`, data
* URIs and any character that could break out of CSS `url("...")` (quotes,
* backslashes, backticks, whitespace).
*/
export function isSafeBackgroundUrl(value: string): boolean {
const URL_SAFE = /^[a-zA-Z0-9/:_\-.?&=%~+#]+$/;
return (
(value.startsWith("/") || /^https?:\/\/[^\s"'`\\]+$/i.test(value)) &&
URL_SAFE.test(value)
);
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { db, WebsiteSetting } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
import { BACKGROUND_DEFAULTS, BACKGROUND_KEYS } from "@/lib/theme-bg";
import {
PRESETS,
THEME_COLOR_KEYS,
@@ -29,6 +30,7 @@ const EXTRA_KEYS = [
"size_heading_h3",
"custom_css",
"theme_preset",
...BACKGROUND_KEYS,
];
const ALL_KEYS: string[] = [
@@ -59,8 +61,9 @@ function fallbackFor(key: string): string {
size_heading_h3: "18",
custom_css: "",
theme_preset: "Atom (golden)",
...BACKGROUND_DEFAULTS,
};
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal EXTRA_KEYS
}
export async function listCustomThemes(): Promise<CustomTheme[]> {
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
normalizeImportedTheme,
serializeThemeExport,
swatchColors,
THEME_ALLOWED_SETTING_KEYS,
THEME_IMPORT_FORMAT,
} from "@/lib/theme-manager-format";
describe("theme import/export format", () => {
it("serialises a theme in the native atom-theme-v1 format", () => {
const json = serializeThemeExport("Summer Gold", {
color_primary: "#f59e0b",
});
const parsed = JSON.parse(json) as Record<string, unknown>;
expect(parsed.format).toBe(THEME_IMPORT_FORMAT);
expect(parsed.name).toBe("Summer Gold");
expect(parsed.settings).toEqual({ color_primary: "#f59e0b" });
});
it("normalises the native export shape", () => {
const result = normalizeImportedTheme(
{
format: THEME_IMPORT_FORMAT,
name: "Pink",
settings: {
color_primary: "#e11d48",
color_primary_dark: "#fb7185",
custom_css: "body{}",
unknown_key: "ignored",
background_image: "/api/media/bg.png",
bad_color: "url(javascript:alert(1))",
},
},
"Fallback",
);
expect(result).toEqual({
name: "Pink",
settings: {
color_primary: "#e11d48",
color_primary_dark: "#fb7185",
custom_css: "body{}",
background_image: "/api/media/bg.png",
},
});
});
it("normalises the /api/themes/export palette shape", () => {
const result = normalizeImportedTheme({
palette: {
light: { color_primary: "#f59e0b", color_text: "#0f172a" },
dark: { color_primary: "#fbbf24", color_text: "#f8fafc" },
},
cssVariables: {},
scopes: [],
meta: { format: THEME_IMPORT_FORMAT },
});
expect(result?.settings).toEqual({
color_primary: "#f59e0b",
color_primary_dark: "#fbbf24",
color_text: "#0f172a",
color_text_dark: "#f8fafc",
});
});
it("rejects non-printable and oversized values", () => {
const result = normalizeImportedTheme({
format: THEME_IMPORT_FORMAT,
settings: {
color_primary: "#fff\nbackground:red",
custom_css: "x".repeat(20001),
},
});
expect(result?.settings).toEqual({});
});
it("rejects unrecognised payloads", () => {
expect(normalizeImportedTheme(null)).toBeNull();
expect(normalizeImportedTheme("nope")).toBeNull();
expect(normalizeImportedTheme({})).toBeNull();
expect(normalizeImportedTheme({ format: "other" })).toBeNull();
});
it("restricts imported settings to the known theme keys", () => {
expect(THEME_ALLOWED_SETTING_KEYS.has("color_primary")).toBe(true);
expect(THEME_ALLOWED_SETTING_KEYS.has("background_image")).toBe(true);
expect(THEME_ALLOWED_SETTING_KEYS.has("custom_themes")).toBe(false);
expect(THEME_ALLOWED_SETTING_KEYS.has("link_color")).toBe(true);
});
it("builds swatch colours from a saved snapshot", () => {
expect(
swatchColors({ color_primary: "#f59e0b", color_accent: "#10b981" }),
).toEqual(["#f59e0b", "#10b981", "#64748b", "#64748b"]);
});
});
File diff suppressed because it is too large. Load diff