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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
Reference in new issue
Block a user