|
|
@@ -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>
|
|
|
);
|
|
|
}
|