feat(theme): animate public background with aurora and particles, polish landing pages
Add background_effect (aurora/particles), background_overlay tint and opacity to the theme manager, rendered site-wide by ThemeVars on every public page. Polish the home and register pages (hero mascot, live stat pulse, date pills, photo strip, CTA band, theme-aware register intro, i18n for home/register section).
This commit is contained in:
1 parent
5923b736fa
commit
d131124515
9 files changed
+573
-25
No files matched your search
@@ -15,9 +15,12 @@ import {
|
||||
} from "@/actions/theme-manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BACKGROUND_EFFECTS,
|
||||
BACKGROUND_POSITIONS,
|
||||
BACKGROUND_REPEATS,
|
||||
BACKGROUND_SIZES,
|
||||
OVERLAY_OPACITY_MAX,
|
||||
OVERLAY_PRESETS,
|
||||
} from "@/lib/theme-bg";
|
||||
import type { CustomTheme } from "@/lib/theme-custom-store";
|
||||
import { serializeThemeExport, swatchColors } from "@/lib/theme-manager-format";
|
||||
@@ -28,6 +31,9 @@ export type ThemeManagerBackground = {
|
||||
position: string;
|
||||
size: string;
|
||||
repeat: string;
|
||||
effect: string;
|
||||
overlay: string;
|
||||
overlayOpacity: string;
|
||||
};
|
||||
|
||||
interface ThemeManagerPanelProps {
|
||||
@@ -133,6 +139,45 @@ function RenameInline({ theme }: { theme: CustomTheme }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayOpacityField({
|
||||
defaultValue = "0",
|
||||
}: {
|
||||
defaultValue?: string;
|
||||
}) {
|
||||
const t = useTranslations(
|
||||
"pages.housekeeping.domains.hotel.routes.themeManager",
|
||||
);
|
||||
const parsed = /^\d{1,2}$/.test(defaultValue)
|
||||
? Math.min(Number(defaultValue), OVERLAY_OPACITY_MAX)
|
||||
: 0;
|
||||
const [value, setValue] = useState(parsed);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] sm:col-span-2 lg:col-span-2">
|
||||
<label
|
||||
htmlFor="background_overlay_opacity"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t("backgroundOverlayOpacityLabel")}</span>
|
||||
<span className="font-semibold text-[var(--admin-text)] tabular-nums">
|
||||
{value}%
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="background_overlay_opacity"
|
||||
type="range"
|
||||
name="background_overlay_opacity"
|
||||
min={0}
|
||||
max={OVERLAY_OPACITY_MAX}
|
||||
step={5}
|
||||
value={value}
|
||||
onChange={(e) => setValue(Number(e.target.value))}
|
||||
className="accent-[var(--admin-accent)] py-2"
|
||||
style={{ border: "none", background: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeManagerPanel({
|
||||
themes,
|
||||
current,
|
||||
@@ -397,8 +442,20 @@ export function ThemeManagerPanel({
|
||||
backgroundRepeat: background.repeat,
|
||||
}
|
||||
: {}),
|
||||
...(background.overlay
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, ${background.overlay} ${Number(background.overlayOpacity) || 0}%, transparent)`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{background.effect !== "none" ? (
|
||||
<p className="mt-1.5 text-xs text-[var(--admin-accent)]">
|
||||
{t(`backgroundEffectPreview`, {
|
||||
effect: t(`backgroundEffect_${background.effect}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -461,6 +518,68 @@ export function ThemeManagerPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] lg:col-span-2">
|
||||
<span>{t("backgroundEffectLabel")}</span>
|
||||
<select
|
||||
name="background_effect"
|
||||
defaultValue={background.effect}
|
||||
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
{BACKGROUND_EFFECTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`backgroundEffect_${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] lg:col-span-2">
|
||||
<label
|
||||
htmlFor="background_overlay"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t("backgroundOverlayLabel")}</span>
|
||||
<input
|
||||
id="background_overlay"
|
||||
name="background_overlay"
|
||||
type="color"
|
||||
value={
|
||||
/^#[0-9a-fA-F]{6}$/.test(background.overlay)
|
||||
? background.overlay
|
||||
: "#000000"
|
||||
}
|
||||
className="h-8 w-12 cursor-pointer rounded-md border bg-[var(--admin-canvas)] px-0.5"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{OVERLAY_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
title={preset.label}
|
||||
onClick={() => {
|
||||
const input = document.getElementById(
|
||||
"background_overlay",
|
||||
) as HTMLInputElement | null;
|
||||
if (input) input.value = preset.value || "#000000";
|
||||
}}
|
||||
className="h-7 w-7 rounded-md border transition-transform hover:scale-110"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
background:
|
||||
preset.value ||
|
||||
"linear-gradient(135deg, #64748b, #94a3b8)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayOpacityField defaultValue={background.overlayOpacity} />
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-4 flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
{t("applyBackground")}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
BACKGROUND_DEFAULTS,
|
||||
BACKGROUND_EFFECTS,
|
||||
BACKGROUND_POSITIONS,
|
||||
BACKGROUND_REPEATS,
|
||||
BACKGROUND_SIZES,
|
||||
type BackgroundEffect,
|
||||
isSafeBackgroundUrl,
|
||||
} from "@/lib/theme-bg";
|
||||
import { themePaletteCss } from "@/lib/theme-css";
|
||||
@@ -16,6 +19,18 @@ import {
|
||||
|
||||
// Injects the DB-driven CSS custom properties into :root / html.dark.
|
||||
// Readable (WCAG) text colors are derived centrally in themePaletteCss.
|
||||
|
||||
// Deterministic particle layout (stable keys, no random state per render).
|
||||
const PARTICLE_DOTS = Array.from({ length: 14 }, (_, i) => ({
|
||||
key: `site-backdrop-dot-${i}`,
|
||||
left: Math.round(((i * 7.13 + 3) % 100) * 100) / 100,
|
||||
duration: 10 + (i % 5) * 1.7,
|
||||
delay: (i % 8) * -1.9,
|
||||
drift: ((i % 3) - 1) * 2.2,
|
||||
size: 3 + (i % 3),
|
||||
alpha: Math.round((0.35 + (i % 4) * 0.12) * 100) / 100,
|
||||
}));
|
||||
|
||||
export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
const g = (k: string, d: string) => siteSettings.get(k, d);
|
||||
const [
|
||||
@@ -62,6 +77,9 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
backgroundPosition,
|
||||
backgroundSize,
|
||||
backgroundRepeat,
|
||||
backgroundEffect,
|
||||
backgroundOverlay,
|
||||
backgroundOverlayOpacity,
|
||||
] = await Promise.all([
|
||||
g("color_primary", "#f59e0b"),
|
||||
g("color_background", "#f8fafc"),
|
||||
@@ -106,6 +124,12 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
g("background_position", BACKGROUND_DEFAULTS.background_position),
|
||||
g("background_size", BACKGROUND_DEFAULTS.background_size),
|
||||
g("background_repeat", BACKGROUND_DEFAULTS.background_repeat),
|
||||
g("background_effect", BACKGROUND_DEFAULTS.background_effect),
|
||||
g("background_overlay", BACKGROUND_DEFAULTS.background_overlay),
|
||||
g(
|
||||
"background_overlay_opacity",
|
||||
BACKGROUND_DEFAULTS.background_overlay_opacity,
|
||||
),
|
||||
]);
|
||||
|
||||
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
||||
@@ -203,11 +227,30 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
? backgroundRepeat
|
||||
: BACKGROUND_DEFAULTS.background_repeat;
|
||||
|
||||
const safeEffect = (BACKGROUND_EFFECTS as readonly string[]).includes(
|
||||
backgroundEffect ?? "",
|
||||
)
|
||||
? (backgroundEffect as BackgroundEffect)
|
||||
: BACKGROUND_EFFECTS[0];
|
||||
|
||||
const safeOverlay = /^#[0-9a-fA-F]{3,8}$/.test(backgroundOverlay ?? "")
|
||||
? (backgroundOverlay as string)
|
||||
: "";
|
||||
const overlayPct = /^\d{1,2}$/.test(backgroundOverlayOpacity ?? "")
|
||||
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
||||
: 0;
|
||||
|
||||
const backgroundCss =
|
||||
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
||||
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
||||
: "";
|
||||
|
||||
const backdropStyle: CSSProperties | undefined = safeOverlay
|
||||
? ({
|
||||
"--background-overlay": `color-mix(in srgb, ${safeOverlay} ${overlayPct}%, transparent)`,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||
@@ -222,6 +265,39 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: customCss }} />
|
||||
) : null}
|
||||
{/* Animated site backdrop: aurora + particles + optional tint. Pure
|
||||
decoration, behind the content, hidden in the admin panel. */}
|
||||
<div
|
||||
className="site-backdrop"
|
||||
data-effect={safeEffect}
|
||||
aria-hidden="true"
|
||||
style={backdropStyle}
|
||||
>
|
||||
<span className="site-backdrop-aurora">
|
||||
<i className="site-backdrop-blob site-backdrop-blob-1" />
|
||||
<i className="site-backdrop-blob site-backdrop-blob-2" />
|
||||
<i className="site-backdrop-blob site-backdrop-blob-3" />
|
||||
</span>
|
||||
<span className="site-backdrop-particles">
|
||||
{PARTICLE_DOTS.map((dot) => (
|
||||
<i
|
||||
key={dot.key}
|
||||
className="site-backdrop-dot"
|
||||
style={
|
||||
{
|
||||
left: `${dot.left}%`,
|
||||
"--dot-duration": `${dot.duration}s`,
|
||||
"--dot-delay": `${dot.delay}s`,
|
||||
"--dot-drift": `${dot.drift}vw`,
|
||||
"--dot-size": dot.size,
|
||||
"--dot-alpha": dot.alpha,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="site-backdrop-overlay" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user