2026-09-19 12:59:28 -04:00
|
|
|
import React, { Suspense, useEffect, useMemo, useState } from 'react';
|
2026-06-14 00:33:04 -04:00
|
|
|
import { useAtomValue } from 'jotai';
|
|
|
|
|
import { settingsAtom } from '../../state/settings';
|
2026-07-02 21:40:07 -04:00
|
|
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
2026-06-29 16:10:29 -04:00
|
|
|
import { zIndices } from '../../styles/zIndex';
|
2026-09-25 13:58:15 -04:00
|
|
|
import { SeasonTheme, SeasonalOverlayProps } from './types';
|
2026-07-24 17:34:51 -04:00
|
|
|
import { resolveSeasonTheme } from './seasonSchedule';
|
2026-09-19 12:59:28 -04:00
|
|
|
|
|
|
|
|
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
|
|
|
|
|
// fetched only when that season is active or previewed in Settings — the
|
|
|
|
|
// date/override decision stays in the main bundle.
|
2026-09-25 13:58:15 -04:00
|
|
|
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<SeasonalOverlayProps> }>) =>
|
2026-09-19 12:59:28 -04:00
|
|
|
React.lazy(load);
|
|
|
|
|
const HalloweenOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const ChristmasOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/Christmas').then((m) => ({ default: m.ChristmasOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const NewYearOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/NewYear').then((m) => ({ default: m.NewYearOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const AutumnOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/Autumn').then((m) => ({ default: m.AutumnOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const AprilFoolsOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/AprilFools').then((m) => ({ default: m.AprilFoolsOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const LunarNewYearOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/LunarNewYear').then((m) => ({ default: m.LunarNewYearOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const ValentinesOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/Valentines').then((m) => ({ default: m.ValentinesOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const StPatricksOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/StPatricks').then((m) => ({ default: m.StPatricksOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const EarthDayOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/EarthDay').then((m) => ({ default: m.EarthDayOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const DeepSpaceOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/DeepSpace').then((m) => ({ default: m.DeepSpaceOverlay })),
|
|
|
|
|
);
|
|
|
|
|
const ArcadeOverlay = lazyOverlay(() =>
|
|
|
|
|
import('./themes/Arcade').then((m) => ({ default: m.ArcadeOverlay })),
|
|
|
|
|
);
|
2026-06-14 00:33:04 -04:00
|
|
|
|
2026-06-30 19:28:28 -04:00
|
|
|
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
|
|
|
|
// of truth, shared with the settings UI). Re-exported here for existing
|
|
|
|
|
// importers that still reach for it from this file.
|
|
|
|
|
export type { SeasonTheme };
|
2026-06-14 00:33:04 -04:00
|
|
|
|
2026-06-15 01:14:56 -04:00
|
|
|
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
2026-06-14 00:33:04 -04:00
|
|
|
|
2026-09-25 13:58:15 -04:00
|
|
|
function buildOverlayContent(
|
|
|
|
|
theme: SeasonTheme,
|
|
|
|
|
reduced: boolean,
|
|
|
|
|
preview = false,
|
|
|
|
|
): React.ReactNode {
|
2026-06-15 01:14:56 -04:00
|
|
|
switch (theme) {
|
|
|
|
|
case 'halloween':
|
2026-09-25 14:50:35 -04:00
|
|
|
return <HalloweenOverlay reduced={reduced} preview={preview} />;
|
2026-06-15 01:14:56 -04:00
|
|
|
case 'christmas':
|
|
|
|
|
return <ChristmasOverlay reduced={reduced} />;
|
|
|
|
|
case 'newyear':
|
|
|
|
|
return <NewYearOverlay reduced={reduced} />;
|
|
|
|
|
case 'autumn':
|
2026-09-25 13:58:15 -04:00
|
|
|
return <AutumnOverlay reduced={reduced} preview={preview} />;
|
2026-06-15 01:14:56 -04:00
|
|
|
case 'aprilfools':
|
|
|
|
|
return <AprilFoolsOverlay reduced={reduced} />;
|
|
|
|
|
case 'lunar':
|
|
|
|
|
return <LunarNewYearOverlay reduced={reduced} />;
|
|
|
|
|
case 'valentines':
|
|
|
|
|
return <ValentinesOverlay reduced={reduced} />;
|
|
|
|
|
case 'stpatricks':
|
|
|
|
|
return <StPatricksOverlay reduced={reduced} />;
|
|
|
|
|
case 'earthday':
|
|
|
|
|
return <EarthDayOverlay reduced={reduced} />;
|
|
|
|
|
case 'deepspace':
|
|
|
|
|
return <DeepSpaceOverlay reduced={reduced} />;
|
|
|
|
|
case 'arcade':
|
|
|
|
|
return <ArcadeOverlay reduced={reduced} />;
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 00:33:04 -04:00
|
|
|
|
2026-06-15 01:14:56 -04:00
|
|
|
// ─── Full-screen overlay (fixed position, used in App) ────────────────────────
|
|
|
|
|
|
|
|
|
|
function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: boolean }) {
|
2026-06-14 00:33:04 -04:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
inset: 0,
|
|
|
|
|
pointerEvents: 'none',
|
2026-06-19 00:15:35 -04:00
|
|
|
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
|
|
|
|
// by it, and below modals (9999) so dialogs are never obscured.
|
2026-06-29 16:10:29 -04:00
|
|
|
zIndex: zIndices.seasonalEffect,
|
2026-06-14 00:33:04 -04:00
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-09-19 12:59:28 -04:00
|
|
|
<Suspense fallback={null}>{buildOverlayContent(theme, reduced)}</Suspense>
|
2026-06-14 00:33:04 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:14:56 -04:00
|
|
|
// ─── Preview overlay (absolute position, contained in a card) ─────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Renders the ambient (reduced-motion) version of a seasonal overlay inside
|
|
|
|
|
* a parent container. The parent must have `position: relative; overflow: hidden`.
|
|
|
|
|
*/
|
|
|
|
|
export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
2026-09-12 01:29:33 -04:00
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
// Size container so overlays can scale/hide fixed-size details (e.g.
|
|
|
|
|
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
|
|
|
|
|
containerType: 'inline-size',
|
|
|
|
|
}}
|
2026-06-15 01:14:56 -04:00
|
|
|
>
|
2026-09-25 13:58:15 -04:00
|
|
|
<Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
|
2026-06-15 01:14:56 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Main exported component ──────────────────────────────────────────────────
|
|
|
|
|
|
2026-06-14 00:33:04 -04:00
|
|
|
export function SeasonalEffect() {
|
|
|
|
|
const settings = useAtomValue(settingsAtom);
|
2026-07-02 21:40:07 -04:00
|
|
|
const reduced = useReducedMotion();
|
2026-07-24 17:34:51 -04:00
|
|
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
2026-06-14 00:33:04 -04:00
|
|
|
|
2026-07-24 17:34:51 -04:00
|
|
|
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
|
|
|
|
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
|
|
|
|
// otherwise the active season is frozen at the value it had on mount.
|
|
|
|
|
const [now, setNow] = useState(() => Date.now());
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (override !== 'auto') return undefined;
|
|
|
|
|
// Refresh on entering auto too: `now` may be a stale mount-time value if we
|
|
|
|
|
// were previously in a pinned/off mode (the interval only runs while auto).
|
|
|
|
|
setNow(Date.now());
|
|
|
|
|
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
|
|
|
|
|
return () => window.clearInterval(id);
|
|
|
|
|
}, [override]);
|
|
|
|
|
|
|
|
|
|
const theme = useMemo<SeasonTheme | null>(
|
|
|
|
|
() => resolveSeasonTheme(override, now),
|
|
|
|
|
[override, now],
|
|
|
|
|
);
|
2026-06-14 00:33:04 -04:00
|
|
|
|
|
|
|
|
if (!theme) return null;
|
2026-06-18 10:37:44 -04:00
|
|
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
|
|
|
|
// wastes GPU and looks cluttered. The settings UI enforces mutual exclusion on write;
|
|
|
|
|
// this guard covers any legacy state already persisted.
|
|
|
|
|
if (settings.chatBackground !== 'none') return null;
|
2026-06-14 00:33:04 -04:00
|
|
|
return <SeasonalOverlay theme={theme} reduced={reduced} />;
|
|
|
|
|
}
|