|
|
@@ -1,13 +1,39 @@
|
|
|
"use client";
|
|
|
|
|
|
-import { useEffect, useState, useSyncExternalStore } from "react";
|
|
|
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
import type { Movie } from "@/types/movie";
|
|
|
import type { RollState } from "@/hooks/use-roll";
|
|
|
|
|
|
+/**
|
|
|
+ * <RollAnimation /> — self-contained presentational component for the
|
|
|
+ * randomizer's dice roll animation.
|
|
|
+ *
|
|
|
+ * Phases (only when `state === "rolling"` and reduced-motion is OFF):
|
|
|
+ * - 0–800ms scatter: posters fly out into a fan
|
|
|
+ * - 800–2000ms eliminate: non-winner posters fade/scale away
|
|
|
+ * - 2000–2500ms settle: winner snaps to center, scaled up
|
|
|
+ * - 2500ms fires `onComplete?.()` exactly once
|
|
|
+ *
|
|
|
+ * Reduced-motion fast path: bypass scatter entirely. Render a 150 ms fade-in
|
|
|
+ * on the winner only.
|
|
|
+ *
|
|
|
+ * Animation strategy: CSS transitions on `transform` / `opacity` only — no
|
|
|
+ * `requestAnimationFrame` loops (jsdom no-ops rAF; React 19 Strict Mode
|
|
|
+ * double-invokes effects). All timers live in a SINGLE useEffect so the
|
|
|
+ * cleanup function is straightforward and Strict-Mode safe.
|
|
|
+ *
|
|
|
+ * XSS: movie titles are rendered as React text children / `alt` attributes
|
|
|
+ * only. No `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
|
|
|
+ *
|
|
|
+ * Perf: ≤16 DOM elements in the scatter; only `transform` / `opacity`
|
|
|
+ * properties animate (GPU-composited).
|
|
|
+ */
|
|
|
+
|
|
|
interface RollAnimationProps {
|
|
|
- rollState: RollState;
|
|
|
- result: Movie | null;
|
|
|
pool: Movie[];
|
|
|
+ winner: Movie | null;
|
|
|
+ state: RollState;
|
|
|
+ onComplete?: () => void;
|
|
|
}
|
|
|
|
|
|
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
|
@@ -34,68 +60,148 @@ function usePrefersReducedMotion(): boolean {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-const MAX_VISIBLE = 8;
|
|
|
+// Phase boundaries (ms from start of "rolling")
|
|
|
+const SCATTER_END_MS = 800;
|
|
|
+const ELIMINATE_END_MS = 2000;
|
|
|
+const SETTLE_END_MS = 2500;
|
|
|
|
|
|
-export function RollAnimation({ rollState, result, pool }: RollAnimationProps) {
|
|
|
- const prefersReducedMotion = usePrefersReducedMotion();
|
|
|
- const [eliminatedCount, setEliminatedCount] = useState(0);
|
|
|
+// Hard cap on scatter DOM elements (perf budget).
|
|
|
+const MAX_SCATTER = 16;
|
|
|
+
|
|
|
+// Reduced-motion fade-in duration.
|
|
|
+const REDUCED_MOTION_FADE_MS = 150;
|
|
|
|
|
|
- const visiblePool = pool.slice(0, MAX_VISIBLE);
|
|
|
- const totalToEliminate = visiblePool.length - 1;
|
|
|
+type Phase = "scatter" | "eliminate" | "settle" | "done";
|
|
|
+
|
|
|
+export function RollAnimation({ pool, winner, state, onComplete }: RollAnimationProps) {
|
|
|
+ const prefersReducedMotion = usePrefersReducedMotion();
|
|
|
+ const [phase, setPhase] = useState<Phase>("scatter");
|
|
|
+ // Drives the reduced-motion fade-in (opacity 0 → 1 via Tailwind transition).
|
|
|
+ const [fadedIn, setFadedIn] = useState(false);
|
|
|
+ // Tracks the previous `state` prop so we can reset the cycle's bookkeeping
|
|
|
+ // at render time when the parent transitions out of "rolling". The React
|
|
|
+ // docs recommend setState-during-render for prop-driven resets.
|
|
|
+ const [prevState, setPrevState] = useState<RollState>(state);
|
|
|
+ // Guard so onComplete fires exactly once per "rolling" → "complete" cycle.
|
|
|
+ // Mutated only inside effect / timer callbacks (never at render time).
|
|
|
+ const completedRef = useRef(false);
|
|
|
+
|
|
|
+ if (prevState !== state) {
|
|
|
+ setPrevState(state);
|
|
|
+ if (state !== "rolling") {
|
|
|
+ // Reset visible animation state synchronously so the next "rolling"
|
|
|
+ // cycle starts from a clean slate.
|
|
|
+ setPhase("scatter");
|
|
|
+ setFadedIn(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (rollState !== "rolling") {
|
|
|
- setEliminatedCount(0);
|
|
|
+ if (state !== "rolling") {
|
|
|
+ completedRef.current = false;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- if (prefersReducedMotion || totalToEliminate <= 0) return;
|
|
|
-
|
|
|
- const intervalMs = 2000 / Math.max(totalToEliminate, 1);
|
|
|
- let count = 0;
|
|
|
- const timer = setInterval(() => {
|
|
|
- count++;
|
|
|
- setEliminatedCount(count);
|
|
|
- if (count >= totalToEliminate) clearInterval(timer);
|
|
|
- }, intervalMs);
|
|
|
-
|
|
|
- return () => clearInterval(timer);
|
|
|
- }, [rollState, prefersReducedMotion, totalToEliminate]);
|
|
|
-
|
|
|
- if (rollState === "idle") return null;
|
|
|
+ completedRef.current = false;
|
|
|
+
|
|
|
+ // Reduced-motion: skip the scatter timeline entirely. Flip the opacity
|
|
|
+ // class one tick after mount so the CSS transition actually animates,
|
|
|
+ // then fire onComplete after the fade window.
|
|
|
+ if (prefersReducedMotion) {
|
|
|
+ const fadeStart = setTimeout(() => {
|
|
|
+ setPhase("done");
|
|
|
+ setFadedIn(true);
|
|
|
+ }, 0);
|
|
|
+ const fadeDone = setTimeout(() => {
|
|
|
+ if (!completedRef.current) {
|
|
|
+ completedRef.current = true;
|
|
|
+ onComplete?.();
|
|
|
+ }
|
|
|
+ }, REDUCED_MOTION_FADE_MS);
|
|
|
+ return () => {
|
|
|
+ clearTimeout(fadeStart);
|
|
|
+ clearTimeout(fadeDone);
|
|
|
+ };
|
|
|
+ }
|
|
|
|
|
|
+ // Full animation timeline. ALL timers are tracked locally and cleared
|
|
|
+ // in the cleanup function — Strict-Mode safe. Initial phase ("scatter")
|
|
|
+ // is handled by the render-time reset above; the timeline only mutates
|
|
|
+ // forward from there.
|
|
|
+ const t1 = setTimeout(() => setPhase("eliminate"), SCATTER_END_MS);
|
|
|
+ const t2 = setTimeout(() => setPhase("settle"), ELIMINATE_END_MS);
|
|
|
+ const t3 = setTimeout(() => {
|
|
|
+ setPhase("done");
|
|
|
+ if (!completedRef.current) {
|
|
|
+ completedRef.current = true;
|
|
|
+ onComplete?.();
|
|
|
+ }
|
|
|
+ }, SETTLE_END_MS);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ clearTimeout(t1);
|
|
|
+ clearTimeout(t2);
|
|
|
+ clearTimeout(t3);
|
|
|
+ };
|
|
|
+ }, [state, prefersReducedMotion, onComplete]);
|
|
|
+
|
|
|
+ if (state === "idle") return null;
|
|
|
+
|
|
|
+ // Reduced-motion render: winner-only fade-in via Tailwind opacity transition.
|
|
|
if (prefersReducedMotion) {
|
|
|
- if (!result) return null;
|
|
|
+ if (!winner) return null;
|
|
|
+ // When state arrives already as "complete" (no rolling step), render at
|
|
|
+ // full opacity immediately — there's no animation cycle to wait for.
|
|
|
+ const visible = fadedIn || state === "complete";
|
|
|
return (
|
|
|
- <div className="flex justify-center py-6">
|
|
|
+ <div className="flex justify-center py-6" data-testid="roll-animation-reduced-motion">
|
|
|
<div
|
|
|
- className={`transition-opacity duration-500 ${rollState === "complete" ? "opacity-100" : "opacity-0"}`}
|
|
|
+ className={`transition-opacity duration-150 ease-out ${visible ? "opacity-100" : "opacity-0"}`}
|
|
|
>
|
|
|
- <PosterThumbnail movie={result} showAlt />
|
|
|
+ <Poster movie={winner} prominent />
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // Full animation: scatter pool. Cap to MAX_SCATTER, ensure winner is in.
|
|
|
+ const scatterPool = buildScatterPool(pool, winner, MAX_SCATTER);
|
|
|
+ const showWinnerProminent = state === "complete" || phase === "settle" || phase === "done";
|
|
|
+
|
|
|
return (
|
|
|
- <div className="flex flex-wrap justify-center gap-3 py-6" aria-hidden="true">
|
|
|
- {visiblePool.map((movie, i) => {
|
|
|
- const isWinner = result && movie.id === result.id;
|
|
|
- const isEliminated = !isWinner && i < eliminatedCount;
|
|
|
- const isRevealed = rollState === "complete" && isWinner;
|
|
|
+ <div
|
|
|
+ className="relative flex flex-wrap justify-center gap-3 py-6 min-h-[10rem]"
|
|
|
+ data-testid="roll-animation-scatter"
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ {scatterPool.map((movie, i) => {
|
|
|
+ const isWinner = winner !== null && movie.id === winner.id;
|
|
|
+ const eliminated = !isWinner && phase !== "scatter";
|
|
|
+
|
|
|
+ // Spread positions deterministically for visual scatter (transform only).
|
|
|
+ const angle = (i / Math.max(scatterPool.length, 1)) * 360;
|
|
|
+ const radius = phase === "scatter" || phase === "eliminate" ? 60 : 0;
|
|
|
+ const tx = Math.cos((angle * Math.PI) / 180) * radius;
|
|
|
+ const ty = Math.sin((angle * Math.PI) / 180) * radius;
|
|
|
+
|
|
|
+ const transform =
|
|
|
+ isWinner && showWinnerProminent
|
|
|
+ ? "translate(0px, 0px) scale(1.4)"
|
|
|
+ : `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px) scale(${eliminated ? 0.4 : 1})`;
|
|
|
|
|
|
return (
|
|
|
<div
|
|
|
key={movie.id}
|
|
|
+ data-testid={isWinner ? "roll-animation-winner" : "roll-animation-poster"}
|
|
|
className={`
|
|
|
- relative w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
|
|
|
- transition-all duration-500 ease-out
|
|
|
- ${isEliminated ? "opacity-0 scale-50 rotate-45 translate-y-8" : ""}
|
|
|
- ${isRevealed ? "scale-110 ring-4 ring-red-500 shadow-xl" : ""}
|
|
|
- ${rollState === "rolling" && !isEliminated ? "animate-pulse" : ""}
|
|
|
+ w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
|
|
|
+ transition-[transform,opacity] duration-500 ease-out
|
|
|
+ ${eliminated ? "opacity-0" : "opacity-100"}
|
|
|
+ ${isWinner && showWinnerProminent ? "ring-4 ring-yellow-400 shadow-2xl z-10" : ""}
|
|
|
`}
|
|
|
+ style={{ transform, willChange: "transform, opacity" }}
|
|
|
>
|
|
|
- <PosterThumbnail movie={movie} />
|
|
|
+ <Poster movie={movie} prominent={isWinner && showWinnerProminent} />
|
|
|
</div>
|
|
|
);
|
|
|
})}
|
|
|
@@ -103,19 +209,40 @@ export function RollAnimation({ rollState, result, pool }: RollAnimationProps) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function PosterThumbnail({ movie, showAlt }: { movie: Movie; showAlt?: boolean }) {
|
|
|
+/**
|
|
|
+ * Build the scatter pool: ensure the winner is included, cap to `max`,
|
|
|
+ * preserve original ordering otherwise. This guarantees the winner has a
|
|
|
+ * stable DOM key so its CSS transitions animate smoothly across phases.
|
|
|
+ */
|
|
|
+function buildScatterPool(pool: Movie[], winner: Movie | null, max: number): Movie[] {
|
|
|
+ if (pool.length === 0) return winner ? [winner] : [];
|
|
|
+ if (pool.length <= max) {
|
|
|
+ if (winner && !pool.some((m) => m.id === winner.id)) {
|
|
|
+ return [winner, ...pool].slice(0, max);
|
|
|
+ }
|
|
|
+ return pool;
|
|
|
+ }
|
|
|
+ const truncated = pool.slice(0, max);
|
|
|
+ if (winner && !truncated.some((m) => m.id === winner.id)) {
|
|
|
+ truncated[truncated.length - 1] = winner;
|
|
|
+ }
|
|
|
+ return truncated;
|
|
|
+}
|
|
|
+
|
|
|
+function Poster({ movie, prominent }: { movie: Movie; prominent?: boolean }) {
|
|
|
if (movie.poster_path) {
|
|
|
return (
|
|
|
+ // eslint-disable-next-line @next/next/no-img-element
|
|
|
<img
|
|
|
src={`https://image.tmdb.org/t/p/w185${movie.poster_path}`}
|
|
|
- alt={showAlt ? `${movie.title} poster` : ""}
|
|
|
- className="w-full h-full object-cover"
|
|
|
+ alt={prominent ? `${movie.title} poster` : ""}
|
|
|
loading="lazy"
|
|
|
+ className="w-full h-full object-cover"
|
|
|
/>
|
|
|
);
|
|
|
}
|
|
|
return (
|
|
|
- <div className="w-full h-full bg-gray-700 flex items-center justify-center text-xs text-gray-400 p-1 text-center">
|
|
|
+ <div className="w-full h-full bg-foreground/10 flex items-center justify-center text-xs text-foreground/60 p-1 text-center">
|
|
|
{movie.title}
|
|
|
</div>
|
|
|
);
|