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:
openhands committed 2026-09-18 12:44:39 +02:00
1 parent 5923b736fa
commit d131124515
9 files changed
+573 -25

No files matched your search

+76
View File
@@ -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>
</>
);
}