feat(housekeeping): add per-hotel theme manager with import/export and background
CI / check (push) Failing after 20s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

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

+349
View File
@@ -0,0 +1,349 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
BACKGROUND_DEFAULTS,
BACKGROUND_POSITIONS,
BACKGROUND_REPEATS,
BACKGROUND_SIZES,
isSafeBackgroundUrl,
} from "@/lib/theme-bg";
import {
type CustomTheme,
deleteCustomThemeStore,
getCustomTheme,
listCustomThemes,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { normalizeImportedTheme } from "@/lib/theme-manager-format";
import { PRESETS } from "@/lib/theme-presets";
import { presetSettings } from "@/lib/theme-settings";
const MANAGER_PATH = "/admin-next/hotel/theme-manager";
const IMPORT_MAX_BYTES = 256 * 1024; // 256KB
const BACKGROUND_MAX_BYTES = 10 * 1024 * 1024; // 10MB
const BACKGROUND_MIME = ["image/png", "image/jpeg", "image/gif", "image/webp"];
async function writeSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
}
async function applySettings(
settings: Record<string, string>,
appliedName: string,
): Promise<void> {
for (const [key, value] of Object.entries(settings)) {
if (value !== "") await writeSetting(key, value);
}
await writeSetting("theme_preset", appliedName);
siteSettings.reload();
revalidatePath("/", "layout");
}
export async function saveCurrentTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
if (name) {
const snapshot = await snapshotCurrentTheme();
try {
const theme = await upsertCustomTheme(name, snapshot);
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Saved custom theme "${theme.name}"`,
});
revalidatePath(MANAGER_PATH);
} catch {
// ignore — page re-renders current state
}
}
redirect(`${MANAGER_PATH}?notice=saved`);
}
export async function applyTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
const theme = id ? await getCustomTheme(id) : null;
if (!theme) redirect(MANAGER_PATH);
try {
await applySettings(theme.settings, theme.name);
revalidatePath(MANAGER_PATH);
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Applied custom theme "${theme.name}"`,
});
} catch {
// ignore — page re-renders current state
}
redirect(`${MANAGER_PATH}?notice=applied`);
}
export async function renameTheme(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
if (id && name) {
try {
const snapshot = await snapshotCurrentTheme();
await upsertCustomTheme(name, snapshot, id);
revalidatePath(MANAGER_PATH);
} catch {
// ignore
}
}
redirect(`${MANAGER_PATH}?notice=renamed`);
}
export async function deleteTheme(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
try {
await deleteCustomThemeStore(id);
revalidatePath(MANAGER_PATH);
} catch {
// ignore
}
redirect(`${MANAGER_PATH}?notice=deleted`);
}
export async function importTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const file = formData.get("file") as File | null;
const fallbackName = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const applyNow = formData.get("apply") === "1";
let imported: CustomTheme | null = null;
try {
if (file && file.size > 0 && file.size <= IMPORT_MAX_BYTES) {
const raw = await file.text();
if (raw.length <= IMPORT_MAX_BYTES) {
const payload = JSON.parse(raw) as unknown;
const normalized = normalizeImportedTheme(payload, fallbackName);
if (normalized) {
const theme = await upsertCustomTheme(
normalized.name,
normalized.settings,
);
if (applyNow) {
await applySettings(theme.settings, theme.name);
}
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Imported theme "${theme.name}"`,
});
revalidatePath(MANAGER_PATH);
imported = theme;
}
}
}
} catch {
// ignore — the error notice is shown below
}
if (imported) redirect(`${MANAGER_PATH}?notice=imported`);
redirect(`${MANAGER_PATH}?notice=importError`);
}
export async function saveBackground(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const image = String(formData.get("background_image") ?? "")
.normalize("NFC")
.trim();
const position = String(formData.get("background_position") ?? "")
.normalize("NFC")
.trim();
const size = String(formData.get("background_size") ?? "")
.normalize("NFC")
.trim();
const repeat = String(formData.get("background_repeat") ?? "")
.normalize("NFC")
.trim();
if (image && !isSafeBackgroundUrl(image)) redirect(MANAGER_PATH);
try {
await writeSetting("background_image", image);
await writeSetting(
"background_position",
(BACKGROUND_POSITIONS as readonly string[]).includes(position)
? position
: BACKGROUND_DEFAULTS.background_position,
);
await writeSetting(
"background_size",
(BACKGROUND_SIZES as readonly string[]).includes(size)
? size
: BACKGROUND_DEFAULTS.background_size,
);
await writeSetting(
"background_repeat",
(BACKGROUND_REPEATS as readonly string[]).includes(repeat)
? repeat
: BACKGROUND_DEFAULTS.background_repeat,
);
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath(MANAGER_PATH);
await logStaffActivity({
staffId: staff.id,
action: "theme_update",
description: "Updated site background",
});
} catch {
// ignore
}
redirect(`${MANAGER_PATH}?notice=background`);
}
export async function clearBackground(): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
for (const key of Object.keys(BACKGROUND_DEFAULTS)) {
await writeSetting(key, "");
}
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath(MANAGER_PATH);
} catch {
// ignore
}
redirect(`${MANAGER_PATH}?notice=background`);
}
export async function uploadBackground(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0 || file.size > BACKGROUND_MAX_BYTES)
redirect(`${MANAGER_PATH}?notice=backgroundError`);
if (!BACKGROUND_MIME.includes(file.type))
redirect(`${MANAGER_PATH}?notice=backgroundError`);
let uploaded = false;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(MEDIA_ROOT, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
await writeSetting("background_image", `/api/media/${name}`);
await writeSetting(
"background_position",
BACKGROUND_DEFAULTS.background_position,
);
await writeSetting(
"background_size",
BACKGROUND_DEFAULTS.background_size,
);
await writeSetting(
"background_repeat",
BACKGROUND_DEFAULTS.background_repeat,
);
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath(MANAGER_PATH);
revalidatePath("/api/media");
uploaded = true;
}
} catch {
// ignore — the error notice is shown below
}
redirect(
`${MANAGER_PATH}?notice=${uploaded ? "background" : "backgroundError"}`,
);
}
export async function applyPresetManager(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "")
.normalize("NFC")
.trim();
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect(MANAGER_PATH);
try {
for (const [key, value] of presetSettings(preset))
await writeSetting(key, value);
await writeSetting("theme_preset", name);
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath(MANAGER_PATH);
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Applied theme preset "${name}"`,
});
} catch {
// ignore
}
redirect(`${MANAGER_PATH}?notice=preset`);
}
/** Read-only data for the Theme Manager page (server side). */
export async function getThemeManagerData() {
await requirePermission(PERMS.SETTINGS_VIEW);
const [themes, current, backgroundValues] = await Promise.all([
listCustomThemes(),
snapshotCurrentTheme(),
siteSettings.getMany(Object.keys(BACKGROUND_DEFAULTS)),
]);
return {
themes,
current,
background: {
image:
backgroundValues.background_image &&
isSafeBackgroundUrl(backgroundValues.background_image)
? backgroundValues.background_image
: "",
position: (BACKGROUND_POSITIONS as readonly string[]).includes(
backgroundValues.background_position ?? "",
)
? (backgroundValues.background_position as string)
: BACKGROUND_DEFAULTS.background_position,
size: (BACKGROUND_SIZES as readonly string[]).includes(
backgroundValues.background_size ?? "",
)
? (backgroundValues.background_size as string)
: BACKGROUND_DEFAULTS.background_size,
repeat: (BACKGROUND_REPEATS as readonly string[]).includes(
backgroundValues.background_repeat ?? "",
)
? (backgroundValues.background_repeat as string)
: BACKGROUND_DEFAULTS.background_repeat,
},
};
}
@@ -0,0 +1,47 @@
import { getTranslations } from "next-intl/server";
import { getThemeManagerData } from "@/actions/theme-manager";
import { ThemeManagerPanel } from "@/components/admin/theme/theme-manager-panel";
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
const MESSAGE_PREFIX = "pages.housekeeping.domains.hotel.routes.themeManager";
const NOTICES = new Set([
"saved",
"applied",
"renamed",
"deleted",
"imported",
"preset",
"background",
"importError",
"backgroundError",
]);
export default async function HousekeepingHotelThemeManagerPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const t = await getTranslations(MESSAGE_PREFIX);
const params = await searchParams;
const first = Array.isArray(params.notice) ? params.notice[0] : params.notice;
const notice = first && NOTICES.has(first) ? first : undefined;
const { themes, current, background } = await getThemeManagerData();
const activeName =
typeof current.theme_preset === "string" && current.theme_preset
? current.theme_preset
: "Atom (golden)";
return (
<HousekeepingPageShell title={t("title")} description={t("description")}>
<ThemeManagerPanel
themes={themes}
current={current}
background={background}
activeName={activeName}
notice={notice}
/>
</HousekeepingPageShell>
);
}
@@ -0,0 +1,499 @@
"use client";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import {
applyPresetManager,
applyTheme,
clearBackground,
deleteTheme,
importTheme,
renameTheme,
saveBackground,
saveCurrentTheme,
uploadBackground,
} from "@/actions/theme-manager";
import { Button } from "@/components/ui/button";
import {
BACKGROUND_POSITIONS,
BACKGROUND_REPEATS,
BACKGROUND_SIZES,
} from "@/lib/theme-bg";
import type { CustomTheme } from "@/lib/theme-custom-store";
import { serializeThemeExport, swatchColors } from "@/lib/theme-manager-format";
import { PRESETS } from "@/lib/theme-presets";
export type ThemeManagerBackground = {
image: string;
position: string;
size: string;
repeat: string;
};
interface ThemeManagerPanelProps {
themes: CustomTheme[];
current: Record<string, string>;
background: ThemeManagerBackground;
activeName: string;
notice?: string;
}
const PRESET_SWATCH_KEYS = [
"color_primary",
"color_accent",
"color_surface",
"color_text",
] as const;
function safeFileStem(name: string): string {
return (name.trim().replace(/[^a-zA-Z0-9-_]+/g, "-") || "theme").slice(0, 60);
}
function downloadJson(name: string, settings: Record<string, string>) {
const blob = new Blob([serializeThemeExport(name, settings)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeFileStem(name)}.atom-theme.json`;
a.click();
URL.revokeObjectURL(url);
}
function Swatches({
settings,
size = 16,
}: {
settings: Record<string, string>;
size?: number;
}) {
return (
<div className="flex items-center">
{swatchColors(settings).map((color) => (
<span
key={color}
style={{
width: size,
height: size,
borderRadius: 4,
background: color,
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
marginLeft: -3,
}}
/>
))}
</div>
);
}
function RenameInline({ theme }: { theme: CustomTheme }) {
const t = useTranslations(
"pages.housekeeping.domains.hotel.routes.themeManager",
);
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(theme.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)]"
>
{t("rename")}
</button>
);
}
return (
<form action={renameTheme} className="flex items-center gap-1">
<input type="hidden" name="id" value={theme.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)" }}
/>
<button
type="submit"
className="text-xs font-semibold text-[var(--admin-accent)]"
>
{t("renameSave")}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-xs text-[var(--admin-text-muted)]"
>
✕
</button>
</form>
);
}
export function ThemeManagerPanel({
themes,
current,
background,
activeName,
notice,
}: ThemeManagerPanelProps) {
const t = useTranslations(
"pages.housekeeping.domains.hotel.routes.themeManager",
);
const [saveName, setSaveName] = useState("");
const [importName, setImportName] = useState("");
const [importError, setImportError] = useState(false);
const uploadFormRef = useRef<HTMLFormElement>(null);
const noticeKey = notice as
| "saved"
| "applied"
| "renamed"
| "deleted"
| "imported"
| "preset"
| "background"
| "importError"
| "backgroundError"
| undefined;
return (
<div className="space-y-6">
{noticeKey ? (
<div
role="status"
className={
noticeKey === "importError" || noticeKey === "backgroundError"
? "rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] px-4 py-3 text-sm text-[var(--admin-text)]"
: "rounded-lg border border-[var(--admin-success)] bg-[var(--admin-surface)] px-4 py-3 text-sm text-[var(--admin-text)]"
}
>
{t(`notice.${noticeKey}`)}
</div>
) : null}
<section className="admin-card p-4">
<h2 className="admin-section-title">{t("currentThemeTitle")}</h2>
<p className="mb-3 text-xs text-[var(--admin-text-muted)]">
{t("currentThemeHint")}
</p>
<div className="flex flex-wrap items-center gap-3">
<Swatches settings={current} size={20} />
<span className="font-semibold text-[var(--admin-text)]">
{activeName}
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<form action={saveCurrentTheme} className="flex items-center gap-2">
<input
type="text"
name="name"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder={t("savePlaceholder")}
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)] w-56"
style={{ borderColor: "var(--admin-border)" }}
/>
<Button type="submit" size="sm">
{t("saveLabel")}
</Button>
</form>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadJson(activeName || "My theme", current)}
>
{t("exportCurrent")}
</Button>
</div>
</div>
</section>
<section className="admin-card p-4">
<h2 className="admin-section-title">{t("savedThemesTitle")}</h2>
<p className="mb-3 text-xs text-[var(--admin-text-muted)]">
{t("savedThemesHint")}
</p>
{themes.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)]">
{t("noThemes")}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{themes.map((theme) => {
const active = theme.name === activeName;
return (
<div
className="admin-card p-3 flex flex-col gap-2"
key={theme.id}
>
<div className="flex items-center justify-between gap-2">
<Swatches settings={theme.settings} size={18} />
{active ? (
<span className="rounded-full border border-[var(--admin-success)] px-2 py-0.5 text-xs font-medium text-[var(--admin-success)]">
{t("active")}
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-[var(--admin-text)]">
{theme.name}
</span>
<RenameInline theme={theme} />
</div>
<div className="flex flex-wrap gap-2">
<form action={applyTheme}>
<input type="hidden" name="id" value={theme.id} />
<Button type="submit" variant="outline" size="sm">
{t("apply")}
</Button>
</form>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadJson(theme.name, theme.settings)}
>
{t("export")}
</Button>
<form
action={deleteTheme}
onSubmit={(e) => {
if (
!window.confirm(
t("deleteConfirm", { name: theme.name }),
)
)
e.preventDefault();
}}
>
<input type="hidden" name="id" value={theme.id} />
<Button type="submit" variant="destructive" size="sm">
{t("delete")}
</Button>
</form>
</div>
</div>
);
})}
</div>
)}
</section>
<section className="admin-card p-4">
<h2 className="admin-section-title">{t("importTitle")}</h2>
<p className="mb-3 text-xs text-[var(--admin-text-muted)]">
{t("importHint")}
</p>
<form action={importTheme} className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("importNameLabel")}</span>
<input
type="text"
name="name"
value={importName}
onChange={(e) => setImportName(e.target.value)}
placeholder={t("importNamePlaceholder")}
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("importFileLabel")}</span>
<input
type="file"
name="file"
accept=".json,application/json"
required
onChange={(e) => {
if (!e.target.files?.length) return;
const file = e.target.files[0];
void file.text().then((raw) => {
try {
JSON.parse(raw);
setImportError(false);
} catch {
setImportError(true);
}
});
}}
className="rounded-lg border bg-[var(--admin-canvas)] px-2 py-2 text-sm text-[var(--admin-text)] file:mr-2"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-[var(--admin-text)]">
<input
type="checkbox"
name="apply"
value="1"
defaultChecked
className="size-4"
/>
{t("importApplyLabel")}
</label>
<Button type="submit" size="sm">
{t("importButton")}
</Button>
</form>
{importError ? (
<p className="mt-2 text-xs text-[var(--admin-error)]">
{t("invalidFile")}
</p>
) : null}
</section>
<section className="admin-card p-4">
<h2 className="admin-section-title">{t("presetsTitle")}</h2>
<div className="flex flex-wrap gap-2">
{Object.entries(PRESETS).map(([name, palette]) => (
<form key={name} action={applyPresetManager}>
<input type="hidden" name="preset" value={name} />
<Button type="submit" variant="outline">
<span className="flex items-center gap-1">
{PRESET_SWATCH_KEYS.map((k) => (
<span
key={k}
style={{
width: 14,
height: 14,
borderRadius: 3,
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
background: palette.light[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
}}
/>
))}
</span>
{name}
</Button>
</form>
))}
</div>
</section>
<section className="admin-card p-4">
<h2 className="admin-section-title">{t("backgroundTitle")}</h2>
<p className="mb-3 text-xs text-[var(--admin-text-muted)]">
{t("backgroundHint")}
</p>
<div className="mb-4">
<p className="mb-1 text-xs text-[var(--admin-text-muted)]">
{t("backgroundPreviewLabel")}
</p>
<div
className="h-32 rounded-lg border bg-gradient-to-t from-[var(--admin-canvas)] to-[var(--admin-surface)]"
style={{
borderColor: "var(--admin-border)",
...(background.image
? {
backgroundImage: `url("${background.image}")`,
backgroundPosition: background.position,
backgroundSize: background.size,
backgroundRepeat: background.repeat,
}
: {}),
}}
/>
</div>
<form
action={saveBackground}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
>
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("backgroundUrlLabel")}</span>
<input
type="text"
name="background_image"
defaultValue={background.image}
placeholder="/api/media/… or https://…"
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
style={{ borderColor: "var(--admin-border)" }}
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("backgroundPositionLabel")}</span>
<select
name="background_position"
defaultValue={background.position}
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
style={{ borderColor: "var(--admin-border)" }}
>
{BACKGROUND_POSITIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("backgroundSizeLabel")}</span>
<select
name="background_size"
defaultValue={background.size}
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
style={{ borderColor: "var(--admin-border)" }}
>
{BACKGROUND_SIZES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)]">
<span>{t("backgroundRepeatLabel")}</span>
<select
name="background_repeat"
defaultValue={background.repeat}
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
style={{ borderColor: "var(--admin-border)" }}
>
{BACKGROUND_REPEATS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<div className="sm:col-span-2 lg:col-span-4 flex flex-wrap gap-2">
<Button type="submit" size="sm">
{t("applyBackground")}
</Button>
</div>
</form>
<div className="mt-3 flex flex-wrap items-center gap-2">
<form ref={uploadFormRef} action={uploadBackground}>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="file"
name="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files?.length)
uploadFormRef.current?.requestSubmit();
}}
/>
<span className="inline-flex items-center justify-center rounded-md border bg-background px-3 py-2 text-sm font-medium transition-all hover:bg-accent hover:text-accent-foreground">
{t("uploadButton")}
</span>
</label>
</form>
{background.image ? (
<form action={clearBackground}>
<Button type="submit" variant="outline" size="sm">
{t("clearBackground")}
</Button>
</form>
) : null}
</div>
</section>
</div>
);
}
+42
View File
@@ -1,4 +1,11 @@
import { siteSettings } from "@/lib/services/site-settings";
import {
BACKGROUND_DEFAULTS,
BACKGROUND_POSITIONS,
BACKGROUND_REPEATS,
BACKGROUND_SIZES,
isSafeBackgroundUrl,
} from "@/lib/theme-bg";
import { themePaletteCss } from "@/lib/theme-css";
import {
FONTS,
@@ -51,6 +58,10 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
adminTextMuted,
adminBorder,
adminSidebarBg,
backgroundImage,
backgroundPosition,
backgroundSize,
backgroundRepeat,
] = await Promise.all([
g("color_primary", "#f59e0b"),
g("color_background", "#f8fafc"),
@@ -91,6 +102,10 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
g("admin_text_muted", ""),
g("admin_border", ""),
g("admin_sidebar_bg", ""),
g("background_image", BACKGROUND_DEFAULTS.background_image),
g("background_position", BACKGROUND_DEFAULTS.background_position),
g("background_size", BACKGROUND_DEFAULTS.background_size),
g("background_repeat", BACKGROUND_DEFAULTS.background_repeat),
]);
// Only hex/keyword color values are admin-controlled — sanitize defensively.
@@ -172,10 +187,37 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
: null;
const safeBgPosition = (BACKGROUND_POSITIONS as readonly string[]).includes(
backgroundPosition ?? "",
)
? backgroundPosition
: BACKGROUND_DEFAULTS.background_position;
const safeBgSize = (BACKGROUND_SIZES as readonly string[]).includes(
backgroundSize ?? "",
)
? backgroundSize
: BACKGROUND_DEFAULTS.background_size;
const safeBgRepeat = (BACKGROUND_REPEATS as readonly string[]).includes(
backgroundRepeat ?? "",
)
? backgroundRepeat
: BACKGROUND_DEFAULTS.background_repeat;
const backgroundCss =
backgroundImage && isSafeBackgroundUrl(backgroundImage)
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
: "";
return (
<>
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
{backgroundCss ? (
<style
nonce={nonce}
dangerouslySetInnerHTML={{ __html: backgroundCss }}
/>
) : null}
{customCss?.trim() ? (
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: customCss }} />
@@ -21,6 +21,12 @@ export const hotelManifest = {
PERMS.CATALOG_EDIT,
),
routes: [
{
id: "theme-manager",
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
href: "/admin-next/hotel/theme-manager",
capability: anyCapability(PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT),
},
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
@@ -580,6 +580,11 @@ describe("housekeeping foundation completion contracts", () => {
expect(
registry.domains.find((domain) => domain.id === "hotel")?.routes,
).toEqual([
expect.objectContaining({
id: "theme-manager",
href: "/admin-next/hotel/theme-manager",
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
}),
expect.objectContaining({
id: "nitro-cleanup",
href: "/admin-next/hotel/nitro-cleanup",
@@ -166,6 +166,15 @@ const expectedManifests = [
PERMS.CATALOG_EDIT,
],
routes: [
{
id: "theme-manager",
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
href: "/admin-next/hotel/theme-manager",
capability: {
mode: "any",
slugs: [PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT],
},
},
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
+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"]);
});
});
+169
View File
@@ -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");
}
+49
View File
@@ -4773,6 +4773,55 @@
"title": "Hotel",
"description": "Rooms, radio, and asset tools",
"routes": {
"themeManager": {
"title": "Theme manager",
"description": "Save, switch, import, export, and publish custom hotel themes (survive updates)",
"currentThemeTitle": "Current theme",
"currentThemeHint": "The palette and background being served to your visitors right now.",
"saveLabel": "Save theme",
"savePlaceholder": "Theme name (e.g. Summer Gold)",
"exportCurrent": "Export",
"savedThemesTitle": "Saved themes",
"savedThemesHint": "Apply any saved theme anytime. Saved themes are stored in the database, so they keep working after CMS updates.",
"noThemes": "No saved themes yet — save one above.",
"active": "Active",
"apply": "Apply",
"export": "Export",
"rename": "Rename",
"renameSave": "OK",
"delete": "Delete",
"deleteConfirm": "Delete theme \"{name}\"?",
"importTitle": "Import a theme",
"importHint": "Upload a `.atom-theme.json` file exported here, or a theme exported from the Theme Builder.",
"importNameLabel": "Name",
"importNamePlaceholder": "Optional override",
"importFileLabel": "Theme file",
"importApplyLabel": "Apply after import",
"importButton": "Import",
"invalidFile": "That file isn't a valid JSON theme.",
"presetsTitle": "Quick start presets",
"backgroundTitle": "Background",
"backgroundHint": "Set a custom background image for the public site using any image URL, or upload one.",
"backgroundPreviewLabel": "Preview",
"backgroundUrlLabel": "Image URL",
"backgroundPositionLabel": "Position",
"backgroundSizeLabel": "Size",
"backgroundRepeatLabel": "Repeat",
"applyBackground": "Apply background",
"uploadButton": "Upload image…",
"clearBackground": "Remove background",
"notice": {
"saved": "Theme saved.",
"applied": "Theme applied.",
"renamed": "Theme renamed.",
"deleted": "Theme deleted.",
"imported": "Theme imported.",
"preset": "Preset applied.",
"background": "Background updated.",
"importError": "That file couldn't be imported. It may be too large or not a theme export.",
"backgroundError": "The image couldn't be uploaded. Use a PNG, JPEG, GIF, or WebP up to 10MB."
}
},
"nitroCleanup": {
"title": "Nitro cleanup",
"description": "Scan and remove fake, broken, and orphaned furni asset files"
+49
View File
@@ -4772,6 +4772,55 @@
"title": "Hotel",
"description": "Stanze, radio e strumenti per gli asset",
"routes": {
"themeManager": {
"title": "Gestione temi",
"description": "Salva, attiva, importa, esporta e pubblica temi personalizzati per l'hotel (sopravvivono agli aggiornamenti)",
"currentThemeTitle": "Tema corrente",
"currentThemeHint": "La palette e lo sfondo attualmente mostrati ai tuoi visitatori.",
"saveLabel": "Salva tema",
"savePlaceholder": "Nome del tema (es. Oro estivo)",
"exportCurrent": "Esporta",
"savedThemesTitle": "Temi salvati",
"savedThemesHint": "Attiva un tema salvato in qualsiasi momento. I temi sono salvati nel database e restano attivi dopo gli aggiornamenti CMS.",
"noThemes": "Nessun tema salvato — salvane uno qui sopra.",
"active": "Attivo",
"apply": "Applica",
"export": "Esporta",
"rename": "Rinomina",
"renameSave": "OK",
"delete": "Elimina",
"deleteConfirm": "Eliminare il tema \"{name}\"?",
"importTitle": "Importa un tema",
"importHint": "Carica un file `.atom-theme.json` esportato qui, oppure un tema esportato dal Theme Builder.",
"importNameLabel": "Nome",
"importNamePlaceholder": "Sostituzione opzionale",
"importFileLabel": "File del tema",
"importApplyLabel": "Applica dopo l'importazione",
"importButton": "Importa",
"invalidFile": "Il file non è un tema JSON valido.",
"presetsTitle": "Preset di partenza",
"backgroundTitle": "Sfondo",
"backgroundHint": "Imposta un'immagine di sfondo personalizzata per il sito pubblico usando un URL o caricando un file.",
"backgroundPreviewLabel": "Anteprima",
"backgroundUrlLabel": "URL immagine",
"backgroundPositionLabel": "Posizione",
"backgroundSizeLabel": "Dimensione",
"backgroundRepeatLabel": "Ripetizione",
"applyBackground": "Applica sfondo",
"uploadButton": "Carica immagine…",
"clearBackground": "Rimuovi sfondo",
"notice": {
"saved": "Tema salvato.",
"applied": "Tema applicato.",
"renamed": "Tema rinominato.",
"deleted": "Tema eliminato.",
"imported": "Tema importato.",
"preset": "Preset applicato.",
"background": "Sfondo aggiornato.",
"importError": "Impossibile importare il file. Potrebbe essere troppo grande o non essere un'esportazione di un tema.",
"backgroundError": "Immagine non caricata. Usa PNG, JPEG, GIF o WebP fino a 10MB."
}
},
"nitroCleanup": {
"title": "Pulizia nitro",
"description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani"