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.
89 lines
2.6 KiB
TypeScript
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>
|
|
);
|
|
}
|