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:
1 parent
55c13b533c
commit
5b4b275b2a
13 files changed
+1376
-1
No files matched your search
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { BACKGROUND_KEYS } from "@/lib/theme-bg";
|
||||
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
|
||||
// Shared by the Theme Manager import/export flow. Pure helpers, kept module-
|
||||
// free so they can be unit-tested without a server.
|
||||
|
||||
export const THEME_IMPORT_FORMAT = "atom-theme-v1" as const;
|
||||
|
||||
export const SWATCH_KEYS = [
|
||||
"color_primary",
|
||||
"color_accent",
|
||||
"color_surface",
|
||||
"color_text",
|
||||
] as const;
|
||||
|
||||
// Non-color settings that may be carried inside an exported theme alongside
|
||||
// the color palette. Mirrors the EXTRA_KEYS list in theme-custom-store.
|
||||
export const EXTRA_SYSTEM_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",
|
||||
...BACKGROUND_KEYS,
|
||||
] as const;
|
||||
|
||||
const COLOR_KEYS = new Set<string>([
|
||||
...THEME_COLOR_KEYS,
|
||||
...THEME_COLOR_KEYS.map((k) => `${k}_dark`),
|
||||
]);
|
||||
|
||||
export const THEME_ALLOWED_SETTING_KEYS = new Set<string>([
|
||||
...COLOR_KEYS,
|
||||
...EXTRA_SYSTEM_KEYS,
|
||||
]);
|
||||
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
const MAX_VALUE_LENGTH = 20000;
|
||||
const MAX_CUSTOM_CSS_LENGTH = 20000;
|
||||
|
||||
function squareJs(value: string): boolean {
|
||||
return /^[\x20-\x7E]+$/.test(value);
|
||||
}
|
||||
|
||||
function cleanSettingValue(key: string, value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
if (typeof value === "number" && Number.isFinite(value))
|
||||
return String(value);
|
||||
return null;
|
||||
}
|
||||
const normalized = value.normalize("NFC").trim();
|
||||
if (normalized.length === 0) return "";
|
||||
if (normalized.length > MAX_VALUE_LENGTH) return null;
|
||||
if (!squareJs(normalized)) return null;
|
||||
if (key === "custom_css" && normalized.length > MAX_CUSTOM_CSS_LENGTH)
|
||||
return null;
|
||||
// Color settings feed CSS custom properties — keep them colour-safe.
|
||||
if (COLOR_KEYS.has(key) && !COLOR_RE.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface NormalizedImportedTheme {
|
||||
name: string;
|
||||
settings: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts both the native theme-manager export (`{ format, name, settings }`)
|
||||
* and the `/api/themes/export` shape (`{ palette: { light, dark }, meta }`).
|
||||
* Returns null when the payload is unrecognised.
|
||||
*/
|
||||
export function normalizeImportedTheme(
|
||||
payload: unknown,
|
||||
fallbackName?: string,
|
||||
): NormalizedImportedTheme | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
|
||||
if (record.format === THEME_IMPORT_FORMAT && record.settings) {
|
||||
if (
|
||||
typeof record.settings !== "object" ||
|
||||
record.settings === null ||
|
||||
Array.isArray(record.settings)
|
||||
)
|
||||
return null;
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name.trim().slice(0, 100)
|
||||
: (fallbackName ?? "Imported theme");
|
||||
return {
|
||||
name,
|
||||
settings: sanitizeSettings(record.settings as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
record.meta &&
|
||||
(record.meta as Record<string, unknown>).format === THEME_IMPORT_FORMAT
|
||||
) {
|
||||
const palette = record.palette as
|
||||
| { light?: Record<string, unknown>; dark?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (!palette || typeof palette !== "object") return null;
|
||||
const settings: Record<string, string> = {};
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const light = palette.light?.[key];
|
||||
if (light != null) {
|
||||
const cleaned = cleanSettingValue(key, light);
|
||||
if (cleaned !== null && cleaned) settings[key] = cleaned;
|
||||
}
|
||||
const dark = palette.dark?.[key];
|
||||
if (dark != null) {
|
||||
const cleaned = cleanSettingValue(`${key}_dark`, dark);
|
||||
if (cleaned !== null && cleaned) settings[`${key}_dark`] = cleaned;
|
||||
}
|
||||
}
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name.trim().slice(0, 100)
|
||||
: (fallbackName ?? "Imported theme");
|
||||
return { name, settings };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeSettings(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (!THEME_ALLOWED_SETTING_KEYS.has(key)) continue;
|
||||
const cleaned = cleanSettingValue(key, value);
|
||||
if (cleaned === null) continue;
|
||||
result[key] = cleaned;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises a theme for export in the native theme-manager format.
|
||||
*/
|
||||
export function serializeThemeExport(
|
||||
name: string,
|
||||
settings: Record<string, string>,
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
format: THEME_IMPORT_FORMAT,
|
||||
name,
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function swatchColors(settings: Record<string, string>): string[] {
|
||||
return SWATCH_KEYS.map((key) => settings[key] ?? "#64748b");
|
||||
}
|
||||
Reference in new issue
Block a user