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