From 5b4b275b2af6b611d55aa003334138e003f5d62f Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 16 Sep 2026 19:45:57 +0200 Subject: [PATCH] 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. --- src/actions/theme-manager.ts | 349 ++++++++++++ .../admin-next/hotel/theme-manager/page.tsx | 47 ++ .../admin/theme/theme-manager-panel.tsx | 499 ++++++++++++++++++ src/components/theme-vars.tsx | 42 ++ .../housekeeping/domains/hotel/manifest.ts | 6 + .../foundation-source-contract.test.ts | 5 + .../housekeeping/foundation/registry.test.ts | 9 + src/lib/theme-bg.ts | 52 ++ src/lib/theme-custom-store.ts | 5 +- src/lib/theme-manager-format.test.ts | 96 ++++ src/lib/theme-manager-format.ts | 169 ++++++ src/messages/en.json | 49 ++ src/messages/it.json | 49 ++ 13 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 src/actions/theme-manager.ts create mode 100644 src/app/admin-next/hotel/theme-manager/page.tsx create mode 100644 src/components/admin/theme/theme-manager-panel.tsx create mode 100644 src/lib/theme-bg.ts create mode 100644 src/lib/theme-manager-format.test.ts create mode 100644 src/lib/theme-manager-format.ts diff --git a/src/actions/theme-manager.ts b/src/actions/theme-manager.ts new file mode 100644 index 00000000..0c0050ea --- /dev/null +++ b/src/actions/theme-manager.ts @@ -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 { + await db + .insert(WebsiteSetting) + .values({ key, value, comment: "Theme (housekeeping)" }) + .onDuplicateKeyUpdate({ set: { value } }); +} + +async function applySettings( + settings: Record, + appliedName: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }, + }; +} diff --git a/src/app/admin-next/hotel/theme-manager/page.tsx b/src/app/admin-next/hotel/theme-manager/page.tsx new file mode 100644 index 00000000..16c312dd --- /dev/null +++ b/src/app/admin-next/hotel/theme-manager/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/admin/theme/theme-manager-panel.tsx b/src/components/admin/theme/theme-manager-panel.tsx new file mode 100644 index 00000000..bbe2b33c --- /dev/null +++ b/src/components/admin/theme/theme-manager-panel.tsx @@ -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; + 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) { + 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; + size?: number; +}) { + return ( +
+ {swatchColors(settings).map((color) => ( + + ))} +
+ ); +} + +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 ( + + ); + } + + return ( +
+ + 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)" }} + /> + + +
+ ); +} + +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(null); + + const noticeKey = notice as + | "saved" + | "applied" + | "renamed" + | "deleted" + | "imported" + | "preset" + | "background" + | "importError" + | "backgroundError" + | undefined; + + return ( +
+ {noticeKey ? ( +
+ {t(`notice.${noticeKey}`)} +
+ ) : null} + +
+

{t("currentThemeTitle")}

+

+ {t("currentThemeHint")} +

+
+ + + {activeName} + +
+
+ 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)" }} + /> + +
+ +
+
+
+ +
+

{t("savedThemesTitle")}

+

+ {t("savedThemesHint")} +

+ {themes.length === 0 ? ( +

+ {t("noThemes")} +

+ ) : ( +
+ {themes.map((theme) => { + const active = theme.name === activeName; + return ( +
+
+ + {active ? ( + + {t("active")} + + ) : null} +
+
+ + {theme.name} + + +
+
+
+ + +
+ +
{ + if ( + !window.confirm( + t("deleteConfirm", { name: theme.name }), + ) + ) + e.preventDefault(); + }} + > + + +
+
+
+ ); + })} +
+ )} +
+ +
+

{t("importTitle")}

+

+ {t("importHint")} +

+
+ + + + +
+ {importError ? ( +

+ {t("invalidFile")} +

+ ) : null} +
+ +
+

{t("presetsTitle")}

+
+ {Object.entries(PRESETS).map(([name, palette]) => ( +
+ + +
+ ))} +
+
+ +
+

{t("backgroundTitle")}

+

+ {t("backgroundHint")} +

+ +
+

+ {t("backgroundPreviewLabel")} +

+
+
+ +
+ + + + +
+ +
+
+
+
+ +
+ {background.image ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/theme-vars.tsx b/src/components/theme-vars.tsx index 0cbbf544..6a072772 100644 --- a/src/components/theme-vars.tsx +++ b/src/components/theme-vars.tsx @@ -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 ? : null}