Sfoglia il codice sorgente

Merge branch 'worktree-agent-a1489efa'

User 2 mesi fa
parent
commit
4737ee3fd0

+ 79 - 0
src/components/dice/roll-bar.tsx

@@ -0,0 +1,79 @@
+"use client";
+
+/**
+ * <RollBar /> — two action buttons mounted above the movie list search bar.
+ *
+ *   🎲 Roll the Dice!  — random winner from the unwatched pool
+ *   🎭 Genre Roll!     — opens the <GenreRollModal> for a filtered roll
+ *
+ * Both buttons are disabled (and carry a visible tooltip) when the list is
+ * still loading OR the unwatched pool is empty. The tooltip strings are
+ * hardcoded — no user/movie data is ever interpolated into `title=`, which
+ * keeps the attribute XSS-safe.
+ *
+ * Minimum tap target: 44×44 px (WCAG 2.1 AA).
+ */
+interface RollBarProps {
+  isLoading: boolean;
+  poolEmpty: boolean;
+  onRoll: () => void;
+  onGenreRoll: () => void;
+}
+
+function disabledReason(isLoading: boolean, poolEmpty: boolean): string | null {
+  if (isLoading) return "Loading list\u2026";
+  if (poolEmpty) return "Nothing to roll";
+  return null;
+}
+
+export function RollBar({ isLoading, poolEmpty, onRoll, onGenreRoll }: RollBarProps) {
+  const reason = disabledReason(isLoading, poolEmpty);
+  const disabled = reason !== null;
+
+  const randomTooltip = reason ?? "Roll the dice for a random pick";
+  const genreTooltip = reason ?? "Pick genres and moods, then roll";
+
+  const baseClasses =
+    "inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold transition-colors";
+  const enabledClasses =
+    "bg-red-600 text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400";
+  const disabledClasses = "bg-gray-700 text-gray-400 cursor-not-allowed";
+
+  return (
+    <div
+      className="flex flex-wrap items-center gap-3"
+      aria-label="Randomizer actions"
+      data-testid="roll-bar"
+    >
+      <button
+        type="button"
+        onClick={onRoll}
+        disabled={disabled}
+        aria-disabled={disabled}
+        title={randomTooltip}
+        data-testid="roll-bar-random"
+        className={`${baseClasses} ${disabled ? disabledClasses : enabledClasses}`}
+      >
+        <span aria-hidden="true">🎲</span>
+        <span>Roll the Dice!</span>
+      </button>
+
+      <button
+        type="button"
+        onClick={onGenreRoll}
+        disabled={disabled}
+        aria-disabled={disabled}
+        title={genreTooltip}
+        data-testid="roll-bar-genre"
+        className={`${baseClasses} ${
+          disabled
+            ? disabledClasses
+            : "bg-purple-600 text-white hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400"
+        }`}
+      >
+        <span aria-hidden="true">🎭</span>
+        <span>Genre Roll!</span>
+      </button>
+    </div>
+  );
+}

+ 105 - 0
src/components/dice/roll-result-card.tsx

@@ -0,0 +1,105 @@
+"use client";
+
+import type { Database } from "@/types/database";
+import { getTMDBImageUrl } from "@/types/tmdb";
+
+/**
+ * <RollResultCard /> — summary card shown under the roll animation once a
+ * winner is chosen. Consumes the DB row shape directly (never the TMDB API
+ * shape — those diverge).
+ *
+ * Interactions:
+ *   - click / Enter / Space on the card opens the existing <ExpandedPanel>
+ *     via the parent's `onOpen` callback
+ *   - the Re-roll button re-runs the roll against the previously captured
+ *     snapshot (useRoll handles that internally)
+ *
+ * XSS: `movie.title`, `movie.year`, and `movie.genres` are rendered as
+ * plain React text children. No `dangerouslySetInnerHTML`, no unescaped
+ * `title=` attributes with user data.
+ */
+type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
+
+interface RollResultCardProps {
+  movie: MovieRow;
+  onReroll: () => void;
+  onOpen: (movie: MovieRow) => void;
+}
+
+export function RollResultCard({ movie, onReroll, onOpen }: RollResultCardProps) {
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
+
+  function handleOpen() {
+    onOpen(movie);
+  }
+
+  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
+    if (e.key === "Enter" || e.key === " ") {
+      e.preventDefault();
+      onOpen(movie);
+    }
+  }
+
+  return (
+    <div
+      className="mx-auto my-4 w-full max-w-md overflow-hidden rounded-xl bg-gray-900/90 shadow-xl ring-1 ring-yellow-400/50"
+      data-testid="roll-result-card"
+    >
+      <div
+        role="button"
+        tabIndex={0}
+        aria-label={`Open details for ${movie.title}`}
+        onClick={handleOpen}
+        onKeyDown={handleKeyDown}
+        className="flex w-full cursor-pointer items-start gap-4 p-4 text-left hover:bg-gray-800/90 focus:outline-none focus:ring-2 focus:ring-yellow-400"
+      >
+        {posterUrl ? (
+          // eslint-disable-next-line @next/next/no-img-element
+          <img
+            src={posterUrl}
+            alt={`${movie.title} poster`}
+            loading="lazy"
+            className="h-36 w-24 flex-shrink-0 rounded-lg object-cover shadow-md"
+          />
+        ) : (
+          <div className="flex h-36 w-24 flex-shrink-0 items-center justify-center rounded-lg bg-gray-700 p-2 text-center text-xs text-gray-400">
+            {movie.title}
+          </div>
+        )}
+
+        <div className="min-w-0 flex-1">
+          <p className="text-xs uppercase tracking-wide text-yellow-400">
+            You rolled
+          </p>
+          <h3 className="mt-1 text-lg font-bold text-white">
+            {movie.title}
+            {movie.year > 0 && (
+              <span className="ml-1 font-normal text-gray-300">
+                ({movie.year})
+              </span>
+            )}
+          </h3>
+          {movie.genres.length > 0 && (
+            <p className="mt-1 text-xs text-gray-400">
+              {movie.genres.slice(0, 4).join(", ")}
+            </p>
+          )}
+          <p className="mt-2 text-xs text-gray-500">Tap to view details</p>
+        </div>
+      </div>
+
+      <div className="flex justify-end border-t border-gray-800 bg-gray-900/60 p-3">
+        <button
+          type="button"
+          onClick={onReroll}
+          aria-label="Re-roll the dice"
+          data-testid="roll-result-reroll"
+          className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400"
+        >
+          <span aria-hidden="true">🎲</span>
+          <span>Re-roll</span>
+        </button>
+      </div>
+    </div>
+  );
+}

+ 124 - 5
src/components/movies/movie-list-client.tsx

@@ -8,6 +8,13 @@ import { useGroupMovies } from "@/hooks/use-group-movies";
 import { useMovieSearch } from "@/hooks/use-movie-search";
 import { useAddMovie } from "@/hooks/use-add-movie";
 import { useRealtimeMovies } from "@/hooks/use-realtime-movies";
+import { useRoll } from "@/hooks/use-roll";
+import { RollBar } from "@/components/dice/roll-bar";
+import { RollAnimation } from "@/components/dice/roll-animation";
+import { RollAnnouncer } from "@/components/dice/roll-announcer";
+import { RollResultCard } from "@/components/dice/roll-result-card";
+import { GenreRollModal } from "@/components/dice/genre-roll-modal";
+import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import type { Database } from "@/types/database";
 
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
@@ -27,14 +34,25 @@ interface MovieListClientProps {
 export function MovieListClient({ groupId, members }: MovieListClientProps) {
   const [searchQuery, setSearchQuery] = useState("");
   const [addingTmdbId, setAddingTmdbId] = useState<number | null>(null);
+  const [selectedMovieId, setSelectedMovieId] = useState<string | null>(null);
+  const [genreModalOpen, setGenreModalOpen] = useState(false);
+  const [noMatchesBanner, setNoMatchesBanner] = useState(false);
+  const [rollPool, setRollPool] = useState<MovieRow[]>([]);
 
-  const { data } = useGroupMovies(groupId);
+  const { data, isLoading } = useGroupMovies(groupId);
   const allMovies = useMemo(() => data?.pages.flat() ?? [], [data]);
 
+  const unwatchedPool = useMemo(
+    () => allMovies.filter((m) => !m.watched),
+    [allMovies],
+  );
+  const poolEmpty = unwatchedPool.length === 0;
+
   const { data: searchData, isLoading: isSearchLoading } = useMovieSearch(searchQuery);
   const tmdbResults = searchData?.results ?? [];
 
   const addMovie = useAddMovie();
+  const { result: winner, rollState, roll } = useRoll();
 
   useRealtimeMovies(groupId);
 
@@ -43,9 +61,18 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
     [members],
   );
 
-  // TODO: Wire selectedMovieId, genre filter, userNames when Unit 3 adds optional PosterGrid props
-  const handleSelect = useCallback((_movie: MovieRow) => {
-    // Will toggle selectedMovieId once PosterGrid accepts it
+  const userNames = useMemo(() => {
+    const m = new Map<string, string>();
+    for (const member of members) m.set(member.id, member.displayName);
+    return m;
+  }, [members]);
+
+  const handleSelect = useCallback((movie: MovieRow) => {
+    setSelectedMovieId((prev) => (prev === movie.id ? null : movie.id));
+  }, []);
+
+  const handleClosePanel = useCallback(() => {
+    setSelectedMovieId(null);
   }, []);
 
   const handleSearch = useCallback((query: string) => {
@@ -65,6 +92,51 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
     [addMovie, groupId],
   );
 
+  const handleRandomRoll = useCallback(() => {
+    if (isLoading || poolEmpty) return;
+    setNoMatchesBanner(false);
+    setRollPool(unwatchedPool);
+    roll(unwatchedPool);
+  }, [isLoading, poolEmpty, unwatchedPool, roll]);
+
+  const handleGenreRollOpen = useCallback(() => {
+    if (isLoading || poolEmpty) return;
+    setGenreModalOpen(true);
+  }, [isLoading, poolEmpty]);
+
+  const handleGenreRollSubmit = useCallback(
+    (payload: { genreIds: number[]; moodKeys: string[] }) => {
+      setGenreModalOpen(false);
+      const { movies: filtered, noMatches } = filterByGenresAndEmotionsStructured(
+        payload,
+        unwatchedPool,
+      );
+      if (noMatches) {
+        setNoMatchesBanner(true);
+        setRollPool(unwatchedPool);
+        roll(unwatchedPool);
+        return;
+      }
+      setNoMatchesBanner(false);
+      setRollPool(filtered);
+      roll(filtered);
+    },
+    [unwatchedPool, roll],
+  );
+
+  const handleReroll = useCallback(() => {
+    // Reuse the captured snapshot inside useRoll for a consistent pool.
+    setNoMatchesBanner(false);
+    roll();
+  }, [roll]);
+
+  const handleOpenWinner = useCallback(
+    (movie: MovieRow) => {
+      setSelectedMovieId(movie.id);
+    },
+    [],
+  );
+
   return (
     <div className="space-y-6">
       <div>
@@ -83,9 +155,56 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
         )}
       </div>
 
+      <RollBar
+        isLoading={isLoading}
+        poolEmpty={poolEmpty}
+        onRoll={handleRandomRoll}
+        onGenreRoll={handleGenreRollOpen}
+      />
+
+      <RollAnnouncer state={rollState} winner={winner} />
+
+      {noMatchesBanner && (
+        <div
+          role="status"
+          aria-live="polite"
+          className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-200"
+          data-testid="roll-no-matches-banner"
+        >
+          No matches — showing full list
+        </div>
+      )}
+
+      {rollState !== "idle" && (
+        <RollAnimation pool={rollPool} winner={winner} state={rollState} />
+      )}
+
+      {rollState === "complete" && winner && (
+        <RollResultCard
+          movie={winner}
+          onReroll={handleReroll}
+          onOpen={handleOpenWinner}
+        />
+      )}
+
       <section aria-label="Movie list" aria-live="polite">
-        <PosterGrid groupId={groupId} users={users} onSelect={handleSelect} />
+        <PosterGrid
+          groupId={groupId}
+          users={users}
+          onSelect={handleSelect}
+          selectedMovieId={selectedMovieId}
+          onClosePanel={handleClosePanel}
+          userNames={userNames}
+        />
       </section>
+
+      {genreModalOpen && (
+        <GenreRollModal
+          onClose={() => setGenreModalOpen(false)}
+          onRoll={handleGenreRollSubmit}
+          loading={false}
+        />
+      )}
     </div>
   );
 }

+ 62 - 0
src/lib/dice/genre-filter.ts

@@ -1,11 +1,73 @@
 import type { Movie } from "@/types/movie";
 import { EMOTION_TO_GENRE_MAP, NOSTALGIC_KEYWORDS, NOSTALGIC_YEAR_CUTOFF } from "@/lib/constants";
+import { TMDB_GENRE_MAP } from "@/types/tmdb";
 
 export interface FilterResult {
   movies: Movie[];
   noMatches: boolean;
 }
 
+/**
+ * Structured variant of `filterByGenresAndEmotions` used by the in-list
+ * Genre Roll flow. Accepts already-resolved genre IDs and mood keys from
+ * `<GenreRollModal>` (U7) so we can skip the string tokenization step.
+ *
+ * Matches each movie against the requested IDs by comparing to both the
+ * raw ID (as a string) AND the TMDB genre name — `movies.genres` in the
+ * database stores names (e.g. "Action"), but tests and legacy code may
+ * pass numeric IDs, so we accept either shape.
+ */
+export function filterByGenresAndEmotionsStructured(
+  payload: { genreIds: number[]; moodKeys: string[] },
+  movies: Movie[],
+): FilterResult {
+  const { genreIds: inputGenreIds, moodKeys } = payload;
+
+  const genreIds = new Set<number>(inputGenreIds);
+  let hasNostalgic = false;
+
+  for (const key of moodKeys) {
+    const normalized = key.toLowerCase();
+    if (NOSTALGIC_KEYWORDS.has(normalized)) {
+      hasNostalgic = true;
+      continue;
+    }
+    const mapping = EMOTION_TO_GENRE_MAP[normalized];
+    if (mapping) {
+      for (const id of mapping.primary) genreIds.add(id);
+      for (const id of mapping.secondary) genreIds.add(id);
+    }
+  }
+
+  if (!hasNostalgic && genreIds.size === 0) {
+    return { movies, noMatches: false };
+  }
+
+  let filtered = movies;
+
+  if (hasNostalgic) {
+    filtered = filtered.filter((m) => m.year < NOSTALGIC_YEAR_CUTOFF);
+  }
+
+  if (genreIds.size > 0) {
+    const acceptable = new Set<string>();
+    for (const id of genreIds) {
+      acceptable.add(String(id));
+      const name = TMDB_GENRE_MAP[id];
+      if (name) acceptable.add(name);
+    }
+    filtered = filtered.filter((m) =>
+      m.genres.some((genre) => acceptable.has(genre)),
+    );
+  }
+
+  if (filtered.length === 0) {
+    return { movies: [], noMatches: true };
+  }
+
+  return { movies: filtered, noMatches: false };
+}
+
 export function filterByGenresAndEmotions(input: string, movies: Movie[]): FilterResult {
   const tokens = tokenize(input);
   if (tokens.length === 0) {