Files
movieloop-frontend/src/components/game/CelebrationOverlay.tsx
T
TehRiehlDeal 5a7e8cebc3 feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
WP-17. Minor: push notifications become usable and nothing a player could
reach was removed. The admin console's jobs need backend 3.0.0.

Push (M35): the whole browser half - the service worker's handlers, the
subscribe/unsubscribe calls, the server's VAPID key - had shipped with
nothing calling it, so no browser ever held a subscription. The toggle
lives in Profile because the permission prompt has to come from a click.
The old isPushSubscribed awaited serviceWorker.ready, which never settles
with no worker registered; it uses getRegistration() now.

Admin: each job pages and the button becomes Continue with the server's
cursor; the recalculate card also stopped printing "undefined leaderboard
entries" for a field the server had not sent for several releases. The
three copy-pasted job cards are one component.

Consolidation: the four game screens share use-game-screen and one search
panel; the review page's static chain is ChainDisplay in a static mode
(21 rendered states verified byte-identical); formatTime and error
extraction exist once; Endless and quick play share a franchise-aware
picker instead of two divergent sort(() => Math.random() - 0.5) copies.
The dead api/index barrel and the fourteen functions only it referenced
are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected
and every preset title is checked against TMDB.
2026-09-12 00:01:42 -07:00

89 lines
2.6 KiB
TypeScript

import { useEffect, useState } from 'react';
const CONFETTI_COLORS = [
'#f44336', '#e91e63', '#9c27b0', '#673ab7',
'#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
'#4caf50', '#8bc34a', '#ffeb3b', '#ff9800',
];
const CONFETTI_COUNT = 20;
interface ConfettiPiece {
color: string;
left: number;
delay: number;
duration: number;
size: number;
round: boolean;
}
/** Always in range (index < CONFETTI_COLORS.length); the fallback only satisfies the type. */
function randomConfettiColor(): string {
return CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)] ?? '#f44336';
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, () => ({
color: randomConfettiColor(),
left: Math.random() * 100,
delay: Math.random() * 1,
duration: 2 + Math.random() * 1.5,
size: 6 + Math.random() * 6,
round: Math.random() > 0.5,
}));
}
/**
* Twenty pieces of confetti falling for three seconds. Vestibular-disorder
* guidance is not to animate at all when the user has asked the OS not to, so
* the overlay renders nothing under `prefers-reduced-motion: reduce` rather
* than a still frame of coloured squares. It is purely decorative - the
* completion modal carries the actual news - so nothing is lost.
*/
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
export default function CelebrationOverlay() {
const [visible, setVisible] = useState(true);
const [reduceMotion] = useState(prefersReducedMotion);
// Rolled once per mount. Randomising in the render body re-rolled every piece
// on each re-render, so confetti visibly jumped mid-flight.
const [pieces] = useState(makeConfetti);
useEffect(() => {
const timer = setTimeout(() => setVisible(false), 3000);
return () => clearTimeout(timer);
}, []);
if (!visible || reduceMotion) return null;
return (
<div
className="pointer-events-none fixed inset-0 z-50 overflow-hidden"
aria-hidden="true"
>
{pieces.map((piece, i) => (
<div
key={i}
className="absolute animate-confetti"
style={{
left: `${piece.left}%`,
top: '-10px',
width: `${piece.size}px`,
height: `${piece.size}px`,
backgroundColor: piece.color,
borderRadius: piece.round ? '50%' : '2px',
animationDelay: `${piece.delay}s`,
animationDuration: `${piece.duration}s`,
}}
/>
))}
</div>
);
}