Browse Source

Merge branch 'worktree-agent-a453bf30'

# Conflicts:
#	src/components/landing/genre-roll-modal.tsx
User 2 months ago
parent
commit
82b1ee21ea

+ 2 - 7
src/app/(public)/page.tsx

@@ -1,7 +1,6 @@
 import Link from "next/link";
 import { Hero } from "@/components/landing/hero";
-import { ReelAnimation } from "@/components/landing/reel-animation";
-import { GenreRollLanding } from "@/components/landing/genre-roll-landing";
+import { CarouselSection } from "@/components/landing/carousel-animation";
 import { AboutSection } from "@/components/landing/about-section";
 import { HowItWorks } from "@/components/landing/how-it-works";
 
@@ -11,11 +10,7 @@ export default function LandingPage() {
       <Hero />
 
       <section className="w-full py-8">
-        <ReelAnimation />
-      </section>
-
-      <section className="flex w-full flex-col items-center gap-4 py-8">
-        <GenreRollLanding />
+        <CarouselSection />
       </section>
 
       <div className="py-6">

+ 264 - 0
src/components/landing/carousel-animation.tsx

@@ -0,0 +1,264 @@
+"use client";
+
+import { useState, useEffect, useRef, useSyncExternalStore } from "react";
+import { getTMDBImageUrl } from "@/types/tmdb";
+import type { TMDBMovie } from "@/types/tmdb";
+import { TeaserCard } from "./teaser-card";
+import { GenreRollModal } from "./genre-roll-modal";
+
+interface ReelPoster {
+  tmdb_id: number;
+  poster_path: string;
+  title: string;
+}
+
+const ITEM_WIDTH = 112; // w-28 = 7rem
+const ITEM_GAP = 12; // gap-3 = 0.75rem
+const POSTER_STRIDE = ITEM_WIDTH + ITEM_GAP; // 124px
+const AUTO_SPEED = 0.5; // px per rAF frame
+const FAST_SPEED = 18; // px per frame during spin
+const SPIN_DURATION = 3500; // ms
+const RESUME_DELAY = 15000; // ms before auto-scroll resumes after settle
+
+type CarouselPhase = "auto-scroll" | "spinning" | "settled";
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeToReducedMotion(callback: () => void) {
+  const mq = window.matchMedia(REDUCED_MOTION_QUERY);
+  mq.addEventListener("change", callback);
+  return () => mq.removeEventListener("change", callback);
+}
+
+function getReducedMotionSnapshot() {
+  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
+}
+
+function getReducedMotionServerSnapshot() {
+  return false;
+}
+
+function usePrefersReducedMotion(): boolean {
+  return useSyncExternalStore(
+    subscribeToReducedMotion,
+    getReducedMotionSnapshot,
+    getReducedMotionServerSnapshot,
+  );
+}
+
+export function CarouselSection() {
+  const [posters, setPosters] = useState<ReelPoster[]>([]);
+  const [phase, setPhase] = useState<CarouselPhase>("auto-scroll");
+  const [result, setResult] = useState<TMDBMovie | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [modalOpen, setModalOpen] = useState(false);
+  const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
+
+  const scrollOffsetRef = useRef(0);
+  const stripRef = useRef<HTMLDivElement>(null);
+  const animRef = useRef<number | null>(null);
+  const spinStartRef = useRef(0);
+  const genreRollBtnRef = useRef<HTMLButtonElement>(null);
+  const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const pendingResultRef = useRef<TMDBMovie | null>(null);
+
+  const prefersReducedMotion = usePrefersReducedMotion();
+
+  useEffect(() => {
+    fetch("/api/tmdb/reel-posters")
+      .then((res) => res.json())
+      .then((data: { posters: ReelPoster[] }) => {
+        if (data.posters?.length) setPosters(data.posters);
+      })
+      .catch(() => {});
+  }, []);
+
+  const SET_WIDTH = posters.length * POSTER_STRIDE;
+
+  useEffect(() => {
+    if (!posters.length) return;
+    if (prefersReducedMotion && phase === "auto-scroll") return;
+
+    function applyTransform() {
+      if (stripRef.current) {
+        stripRef.current.style.transform = `translateX(-${scrollOffsetRef.current}px)`;
+      }
+    }
+
+    function tick(timestamp: number) {
+      if (phase === "auto-scroll") {
+        scrollOffsetRef.current += AUTO_SPEED;
+        if (scrollOffsetRef.current >= SET_WIDTH) {
+          scrollOffsetRef.current -= SET_WIDTH;
+        }
+        applyTransform();
+      } else if (phase === "spinning") {
+        const elapsed = timestamp - spinStartRef.current;
+        if (elapsed >= SPIN_DURATION) {
+          // Snap to nearest poster
+          const snapped =
+            Math.round(scrollOffsetRef.current / POSTER_STRIDE) * POSTER_STRIDE;
+          scrollOffsetRef.current = snapped % SET_WIDTH;
+          applyTransform();
+          const idx =
+            Math.round(scrollOffsetRef.current / POSTER_STRIDE) % posters.length;
+          setHighlightIndex(idx);
+          setPhase("settled");
+          setResult(pendingResultRef.current);
+          setLoading(false);
+          return;
+        }
+        const progress = elapsed / SPIN_DURATION;
+        const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
+        const currentSpeed = FAST_SPEED * (1 - eased);
+        scrollOffsetRef.current += currentSpeed;
+        if (scrollOffsetRef.current >= SET_WIDTH) {
+          scrollOffsetRef.current -= SET_WIDTH;
+        }
+        applyTransform();
+      }
+
+      if (phase !== "settled") {
+        animRef.current = requestAnimationFrame(tick);
+      }
+    }
+
+    animRef.current = requestAnimationFrame(tick);
+
+    return () => {
+      if (animRef.current) cancelAnimationFrame(animRef.current);
+    };
+  }, [phase, posters.length, prefersReducedMotion, SET_WIDTH]);
+
+  useEffect(() => {
+    if (phase === "settled") {
+      resumeTimerRef.current = setTimeout(() => {
+        setPhase("auto-scroll");
+        setResult(null);
+        setHighlightIndex(null);
+      }, RESUME_DELAY);
+    }
+    return () => {
+      if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current);
+    };
+  }, [phase]);
+
+  const startRoll = (movies: TMDBMovie[]) => {
+    const chosen = movies[Math.floor(Math.random() * movies.length)];
+    pendingResultRef.current = chosen;
+
+    if (posters.length > 0 && !prefersReducedMotion) {
+      spinStartRef.current = performance.now();
+      setPhase("spinning");
+    } else {
+      setResult(chosen);
+      setLoading(false);
+      setPhase("settled");
+    }
+  };
+
+  const fetchAndRoll = async (url: string) => {
+    if (phase === "spinning" || loading) return;
+    setResult(null);
+    setHighlightIndex(null);
+    setLoading(true);
+
+    try {
+      const res = await fetch(url);
+      const data: { results: TMDBMovie[] } = await res.json();
+      const movies = data.results ?? [];
+
+      if (movies.length === 0) {
+        setLoading(false);
+        return;
+      }
+
+      startRoll(movies);
+    } catch {
+      setLoading(false);
+    }
+  };
+
+  const handleRandomRoll = () => fetchAndRoll("/api/tmdb/popular");
+
+  const handleGenreRoll = (genreIds: number[]) => {
+    setModalOpen(false);
+    return fetchAndRoll(`/api/tmdb/discover?with_genres=${genreIds.join(",")}`);
+  };
+
+  const handleModalClose = () => {
+    setModalOpen(false);
+    genreRollBtnRef.current?.focus();
+  };
+
+  const tripled = posters.length > 0 ? [...posters, ...posters, ...posters] : [];
+
+  return (
+    <div className="flex flex-col items-center gap-6">
+      <div className="w-full overflow-hidden">
+        <div ref={stripRef} className="flex gap-3" aria-label="Movie poster carousel">
+          {tripled.map((poster, i) => {
+            const url = getTMDBImageUrl(poster.poster_path, "reel");
+            const isHighlighted =
+              phase === "settled" &&
+              highlightIndex !== null &&
+              i % posters.length === highlightIndex;
+            return (
+              <div
+                key={`${poster.tmdb_id}-${i}`}
+                className={`h-40 w-28 flex-shrink-0 transition-transform duration-300 ${
+                  isHighlighted ? "z-10 scale-110 ring-2 ring-foreground/50" : ""
+                }`}
+              >
+                {url && (
+                  /* eslint-disable-next-line @next/next/no-img-element */
+                  <img
+                    src={url}
+                    alt=""
+                    aria-hidden="true"
+                    loading="lazy"
+                    className="h-full w-full rounded-lg object-cover"
+                  />
+                )}
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      <div className="flex gap-3">
+        <button
+          onClick={handleRandomRoll}
+          disabled={phase === "spinning" || loading}
+          className="rounded-xl bg-foreground px-8 py-3 text-lg font-semibold text-background transition-opacity hover:opacity-90 disabled:opacity-50"
+        >
+          {phase === "spinning" ? "Rolling..." : "Roll the Dice"}
+        </button>
+        <button
+          ref={genreRollBtnRef}
+          onClick={() => setModalOpen(true)}
+          disabled={phase === "spinning" || loading}
+          className="rounded-xl border border-foreground/20 px-8 py-3 text-lg font-semibold transition-colors hover:bg-foreground/5 disabled:opacity-50"
+        >
+          Genre Roll
+        </button>
+      </div>
+
+      <div aria-live="polite" className="min-h-[1px]">
+        {result && (
+          <div className="mt-4 flex justify-center">
+            <TeaserCard movie={result} />
+          </div>
+        )}
+      </div>
+
+      {modalOpen && (
+        <GenreRollModal
+          onClose={handleModalClose}
+          onRoll={handleGenreRoll}
+          loading={loading}
+        />
+      )}
+    </div>
+  );
+}

+ 0 - 119
src/components/landing/genre-roll-landing.tsx

@@ -1,119 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { TMDB_GENRE_MAP, type TMDBMovie } from "@/types/tmdb";
-import { EMOTION_TO_GENRE_MAP, NOSTALGIC_KEYWORDS, NOSTALGIC_YEAR_CUTOFF } from "@/lib/constants";
-import { TeaserCard } from "./teaser-card";
-
-const GENRE_NAME_TO_ID = Object.fromEntries(
-  Object.entries(TMDB_GENRE_MAP).map(([id, name]) => [name.toLowerCase(), Number(id)]),
-);
-
-function parseInput(input: string): { genreIds: number[]; yearCutoff: string | null } {
-  const terms = input
-    .toLowerCase()
-    .split(",")
-    .map((t) => t.trim())
-    .filter(Boolean);
-
-  const genreIds = new Set<number>();
-  let yearCutoff: string | null = null;
-
-  for (const term of terms) {
-    // Check direct genre name match
-    if (GENRE_NAME_TO_ID[term] !== undefined) {
-      genreIds.add(GENRE_NAME_TO_ID[term]);
-      continue;
-    }
-
-    // Check emotion mapping
-    const emotion = EMOTION_TO_GENRE_MAP[term];
-    if (emotion) {
-      if (NOSTALGIC_KEYWORDS.has(term)) {
-        yearCutoff = `${NOSTALGIC_YEAR_CUTOFF}-12-31`;
-      }
-      for (const id of emotion.primary) genreIds.add(id);
-      if (genreIds.size === 0) {
-        for (const id of emotion.secondary) genreIds.add(id);
-      }
-    }
-  }
-
-  return { genreIds: Array.from(genreIds), yearCutoff };
-}
-
-export function GenreRollLanding() {
-  const [input, setInput] = useState("");
-  const [result, setResult] = useState<TMDBMovie | null>(null);
-  const [loading, setLoading] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-
-  const handleRoll = async () => {
-    if (loading || !input.trim()) return;
-    setResult(null);
-    setError(null);
-    setLoading(true);
-
-    try {
-      const { genreIds, yearCutoff } = parseInput(input);
-
-      const params = new URLSearchParams();
-      if (genreIds.length > 0) params.set("with_genres", genreIds.join(","));
-      if (yearCutoff) params.set("primary_release_date.lte", yearCutoff);
-
-      const res = await fetch(`/api/tmdb/discover?${params.toString()}`);
-      const data: { results: TMDBMovie[] } = await res.json();
-      const movies = data.results ?? [];
-
-      if (movies.length === 0) {
-        setError("No movies found. Try different genres or moods.");
-        setLoading(false);
-        return;
-      }
-
-      const chosen = movies[Math.floor(Math.random() * movies.length)];
-      setResult(chosen);
-    } catch {
-      setError("Something went wrong. Please try again.");
-    } finally {
-      setLoading(false);
-    }
-  };
-
-  return (
-    <div className="flex w-full max-w-md flex-col items-center gap-4 px-4">
-      <label htmlFor="genre-input" className="text-sm font-medium text-foreground/70">
-        Enter genres or moods (comma-separated)
-      </label>
-      <div className="flex w-full gap-2">
-        <input
-          id="genre-input"
-          type="text"
-          value={input}
-          onChange={(e) => setInput(e.target.value)}
-          onKeyDown={(e) => {
-            if (e.key === "Enter") handleRoll();
-          }}
-          placeholder="e.g. comedy, scary, romance"
-          className="flex-1 rounded-lg border border-foreground/20 bg-background px-4 py-2 text-sm placeholder:text-foreground/30 focus:border-foreground/40 focus:outline-none"
-        />
-        <button
-          onClick={handleRoll}
-          disabled={loading || !input.trim()}
-          className="rounded-lg bg-foreground/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-foreground/20 disabled:opacity-50"
-        >
-          {loading ? "Rolling..." : "Genre Roll"}
-        </button>
-      </div>
-
-      <div aria-live="polite" className="min-h-[1px] w-full">
-        {error && <p className="text-center text-sm text-red-500">{error}</p>}
-        {result && (
-          <div className="mt-2 flex justify-center">
-            <TeaserCard movie={result} />
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}

+ 0 - 244
src/components/landing/reel-animation.tsx

@@ -1,244 +0,0 @@
-"use client";
-
-import { useState, useEffect, useRef, useMemo } from "react";
-import { getTMDBImageUrl } from "@/types/tmdb";
-import type { TMDBMovie } from "@/types/tmdb";
-import { TeaserCard } from "./teaser-card";
-
-interface ReelPoster {
-  tmdb_id: number;
-  poster_path: string;
-  title: string;
-}
-
-const REEL_COUNT = 3;
-const SPIN_DURATION_MS = 4000;
-const STAGGER_MS = 400;
-const ITEM_HEIGHT = 112; // h-28 = 7rem = 112px
-
-function ReelPlaceholder({ label }: { label: string }) {
-  return (
-    <div
-      className="flex h-28 w-20 items-center justify-center rounded-lg bg-foreground/5"
-      role="img"
-      aria-label={label}
-    >
-      <span className="text-2xl text-foreground/20" aria-hidden="true">
-        ?
-      </span>
-    </div>
-  );
-}
-
-function usePrefersReducedMotion(): boolean {
-  return useMemo(
-    () =>
-      typeof window !== "undefined" &&
-      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
-    [],
-  );
-}
-
-type ReelPhase = "idle" | "spinning" | "settled";
-
-function SingleReel({
-  posters,
-  spinning,
-  finalIndex,
-  delayMs,
-  reelIndex,
-}: {
-  posters: ReelPoster[];
-  spinning: boolean;
-  finalIndex: number;
-  delayMs: number;
-  reelIndex: number;
-}) {
-  const [offset, setOffset] = useState(0);
-  const [phase, setPhase] = useState<ReelPhase>("idle");
-  const animRef = useRef<number | null>(null);
-  const prefersReducedMotion = usePrefersReducedMotion();
-
-  const totalHeight = posters.length * ITEM_HEIGHT;
-
-  if (spinning && phase === "idle") {
-    if (prefersReducedMotion) {
-      setOffset(finalIndex * ITEM_HEIGHT);
-      setPhase("settled");
-    } else {
-      setPhase("spinning");
-    }
-  }
-  if (!spinning && phase === "settled") {
-    setPhase("idle");
-  }
-
-  useEffect(() => {
-    if (phase !== "spinning") return;
-
-    let startTime = 0;
-
-    function tick(timestamp: number) {
-      if (!startTime) startTime = timestamp;
-      const elapsed = timestamp - startTime;
-
-      if (elapsed < delayMs) {
-        animRef.current = requestAnimationFrame(tick);
-        return;
-      }
-
-      const spinElapsed = elapsed - delayMs;
-
-      if (spinElapsed >= SPIN_DURATION_MS) {
-        setOffset(finalIndex * ITEM_HEIGHT);
-        setPhase("settled");
-        return;
-      }
-
-      // Deceleration with easeOutCubic
-      const progress = spinElapsed / SPIN_DURATION_MS;
-      const eased = 1 - Math.pow(1 - progress, 3);
-
-      // Spin through multiple full cycles + land on finalIndex
-      const totalSpin = totalHeight * 3 + finalIndex * ITEM_HEIGHT;
-      const currentOffset = eased * totalSpin;
-      setOffset(currentOffset % totalHeight);
-
-      animRef.current = requestAnimationFrame(tick);
-    }
-
-    animRef.current = requestAnimationFrame(tick);
-
-    return () => {
-      if (animRef.current) cancelAnimationFrame(animRef.current);
-    };
-  }, [phase, finalIndex, delayMs, totalHeight]);
-
-  if (phase === "idle") {
-    return <ReelPlaceholder label={`Reel ${reelIndex + 1} ready`} />;
-  }
-
-  return (
-    <div
-      className="relative h-28 w-20 overflow-hidden rounded-lg bg-foreground/5"
-      role="img"
-      aria-label={
-        phase === "settled" ? `Reel ${reelIndex + 1} stopped` : `Reel ${reelIndex + 1} spinning`
-      }
-    >
-      <div className="absolute left-0 w-full" style={{ transform: `translateY(-${offset}px)` }}>
-        {posters.map((poster, i) => {
-          const url = getTMDBImageUrl(poster.poster_path, "reel");
-          return (
-            <div key={`${poster.tmdb_id}-${i}`} className="h-28 w-20 flex-shrink-0">
-              {url && (
-                /* eslint-disable-next-line @next/next/no-img-element */
-                <img
-                  src={url}
-                  alt=""
-                  aria-hidden="true"
-                  loading="lazy"
-                  className="h-full w-full object-cover"
-                />
-              )}
-            </div>
-          );
-        })}
-      </div>
-    </div>
-  );
-}
-
-export function ReelAnimation() {
-  const [posters, setPosters] = useState<ReelPoster[]>([]);
-  const [spinning, setSpinning] = useState(false);
-  const [result, setResult] = useState<TMDBMovie | null>(null);
-  const [loading, setLoading] = useState(false);
-  const [finalIndices, setFinalIndices] = useState([0, 0, 0]);
-
-  useEffect(() => {
-    fetch("/api/tmdb/reel-posters")
-      .then((res) => res.json())
-      .then((data: { posters: ReelPoster[] }) => {
-        if (data.posters?.length) setPosters(data.posters);
-      })
-      .catch(() => {});
-  }, []);
-
-  const handleRoll = async () => {
-    if (spinning || loading) return;
-    setResult(null);
-    setLoading(true);
-
-    try {
-      const res = await fetch("/api/tmdb/popular");
-      const data: { results: TMDBMovie[] } = await res.json();
-      const movies = data.results ?? [];
-
-      if (movies.length === 0) {
-        setLoading(false);
-        return;
-      }
-
-      const chosen = movies[Math.floor(Math.random() * movies.length)];
-
-      if (posters.length > 0) {
-        setFinalIndices([
-          Math.floor(Math.random() * posters.length),
-          Math.floor(Math.random() * posters.length),
-          Math.floor(Math.random() * posters.length),
-        ]);
-        setSpinning(true);
-
-        const maxDuration = SPIN_DURATION_MS + STAGGER_MS * (REEL_COUNT - 1) + 200;
-        setTimeout(() => {
-          setSpinning(false);
-          setResult(chosen);
-          setLoading(false);
-        }, maxDuration);
-      } else {
-        setResult(chosen);
-        setLoading(false);
-      }
-    } catch {
-      setLoading(false);
-    }
-  };
-
-  return (
-    <div className="flex flex-col items-center gap-6">
-      <div className="flex gap-3" aria-label="Slot machine reels">
-        {posters.length > 0
-          ? Array.from({ length: REEL_COUNT }).map((_, i) => (
-              <SingleReel
-                key={i}
-                posters={posters}
-                spinning={spinning}
-                finalIndex={finalIndices[i]}
-                delayMs={i * STAGGER_MS}
-                reelIndex={i}
-              />
-            ))
-          : Array.from({ length: REEL_COUNT }).map((_, i) => (
-              <ReelPlaceholder key={i} label={`Reel ${i + 1} ready`} />
-            ))}
-      </div>
-
-      <button
-        onClick={handleRoll}
-        disabled={spinning || loading}
-        className="rounded-xl bg-foreground px-8 py-3 text-lg font-semibold text-background transition-opacity hover:opacity-90 disabled:opacity-50"
-      >
-        {spinning ? "Rolling..." : "Roll the Dice"}
-      </button>
-
-      <div aria-live="polite" className="min-h-[1px]">
-        {result && (
-          <div className="mt-4 flex justify-center">
-            <TeaserCard movie={result} />
-          </div>
-        )}
-      </div>
-    </div>
-  );
-}