Files
EpicNext-Cms/src/components/theme-vars.tsx
T
openhands 5b4b275b2a
CI / check (push) Failing after 20s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
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.
2026-09-16 19:45:57 +02:00

228 lines
7.1 KiB
TypeScript

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,
PRESETS,
THEME_COLOR_KEYS,
type ThemePalette,
} from "@/lib/theme-presets";
// Injects the DB-driven CSS custom properties into :root / html.dark.
// Readable (WCAG) text colors are derived centrally in themePaletteCss.
export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
const g = (k: string, d: string) => siteSettings.get(k, d);
const [
primary,
background,
surface,
dropdown,
navbar,
navbarText,
text,
textMuted,
accent,
success,
warning,
error,
info,
buttonColor,
buttonText,
buttonSecondary,
buttonSecondaryText,
buttonDanger,
buttonDangerText,
buttonOutline,
buttonOutlineText,
linkColor,
linkHover,
borderColor,
panelBorderColor,
gradientFrom,
gradientTo,
borderRadius,
fontKey,
h1,
h2,
h3,
customCss,
adminCanvas,
adminSurface,
adminText,
adminTextMuted,
adminBorder,
adminSidebarBg,
backgroundImage,
backgroundPosition,
backgroundSize,
backgroundRepeat,
] = await Promise.all([
g("color_primary", "#f59e0b"),
g("color_background", "#f8fafc"),
g("color_surface", "#ffffff"),
g("color_dropdown", "#ffffff"),
g("color_navbar", "#ffffff"),
g("color_navbar_text", "#1e293b"),
g("color_text", "#0f172a"),
g("color_text_muted", "#64748b"),
g("color_accent", "#10b981"),
g("color_success", "#16a34a"),
g("color_warning", "#eab308"),
g("color_error", "#ef4444"),
g("color_info", "#0ea5e9"),
g("button_primary_color", "#f59e0b"),
g("button_text_color", "#1e293b"),
g("button_secondary_color", "#22c55e"),
g("button_secondary_text_color", "#ffffff"),
g("button_danger_color", "#ef4444"),
g("button_danger_text_color", "#ffffff"),
g("button_outline_color", "#eeb425"),
g("button_outline_text_color", "#1a1a2e"),
g("link_color", "#eeb425"),
g("link_hover_color", "#cf9d15"),
g("border_color", "#eeb425"),
g("panel_border_color", "#e9b124"),
g("gradient_from", "#f59e0b"),
g("gradient_to", "#10b981"),
g("border_radius", "12"),
g("font_family", "nunito"),
g("size_heading_h1", "30"),
g("size_heading_h2", "22"),
g("size_heading_h3", "18"),
g("custom_css", ""),
g("admin_canvas", ""),
g("admin_surface", ""),
g("admin_text", ""),
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.
const safe = (v: string | null, d: string) =>
v && /^[#a-zA-Z0-9(),.\s%-]+$/.test(v) ? v : d;
const px = (v: string | null, d: string) =>
/^\d{1,3}$/.test(v ?? "") ? (v as string) : d;
const lightPalette = {
color_primary: safe(primary, "#f59e0b"),
color_background: safe(background, "#f8fafc"),
color_surface: safe(surface, "#ffffff"),
color_dropdown: safe(dropdown, "#ffffff"),
color_navbar: safe(navbar, "#ffffff"),
color_navbar_text: safe(navbarText, "#1e293b"),
color_text: safe(text, "#0f172a"),
color_text_muted: safe(textMuted, "#64748b"),
color_accent: safe(accent, "#10b981"),
color_success: safe(success, "#16a34a"),
color_warning: safe(warning, "#eab308"),
color_error: safe(error, "#ef4444"),
color_info: safe(info, "#0ea5e9"),
button_primary_color: safe(buttonColor, "#f59e0b"),
button_text_color: safe(buttonText, "#1e293b"),
button_secondary_color: safe(buttonSecondary, "#22c55e"),
button_secondary_text_color: safe(buttonSecondaryText, "#ffffff"),
button_danger_color: safe(buttonDanger, "#ef4444"),
button_danger_text_color: safe(buttonDangerText, "#ffffff"),
button_outline_color: safe(buttonOutline, "#eeb425"),
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"),
link_color: safe(linkColor, "#eeb425"),
link_hover_color: safe(linkHover, "#cf9d15"),
border_color: safe(borderColor, "#eeb425"),
panel_border_color: safe(panelBorderColor, "#e9b124"),
gradient_from: safe(gradientFrom, "#f59e0b"),
gradient_to: safe(gradientTo, "#10b981"),
} satisfies ThemePalette;
const adminOverrides = {
...(adminCanvas ? { canvas: safe(adminCanvas, "#0b0f19") } : {}),
...(adminSurface ? { surface: safe(adminSurface, "#151b27") } : {}),
...(adminText ? { text: safe(adminText, "#f8fafc") } : {}),
...(adminTextMuted ? { muted: safe(adminTextMuted, "#94a3b8") } : {}),
...(adminBorder ? { border: safe(adminBorder, "#2a3448") } : {}),
...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "#080c18") } : {}),
};
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
const presetName =
(await g("theme_preset", "Atom (golden)")) ?? "Atom (golden)";
const preset = PRESETS[presetName] ?? PRESETS["Atom (golden)"];
const darkPalette = Object.fromEntries(
await Promise.all(
THEME_COLOR_KEYS.map(
async (key) =>
[
key,
safe(await g(`${key}_dark`, preset.dark[key]), preset.dark[key]),
] as const,
),
),
) as ThemePalette;
const typography = [
`--border-radius:${px(borderRadius, "12")}px`,
`--font-family:${font.stack}`,
`--size-heading-h1:${px(h1, "30")}px`,
`--size-heading-h2:${px(h2, "22")}px`,
`--size-heading-h3:${px(h3, "18")}px`,
].join(";");
const lightCss = themePaletteCss(":root", lightPalette, adminOverrides);
const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
// Append typography inside :root block.
const css = `${lightCss.slice(0, -1)}${typography};}${darkCss}`;
const googleHref = font.google
? `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 }} />
) : null}
</>
);
}