movie-list-client.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. "use client";
  2. import { useCallback, useMemo, useState } from "react";
  3. import { SearchBar } from "./search-bar";
  4. import { SearchResults } from "./search-results";
  5. import { PosterGrid } from "./poster-grid";
  6. import { useGroupMovies } from "@/hooks/use-group-movies";
  7. import { useMovieSearch } from "@/hooks/use-movie-search";
  8. import { useAddMovie } from "@/hooks/use-add-movie";
  9. import { useRealtimeMovies } from "@/hooks/use-realtime-movies";
  10. import { useRoll } from "@/hooks/use-roll";
  11. import { useToggleWatched } from "@/hooks/use-toggle-watched";
  12. import { RollBar } from "@/components/dice/roll-bar";
  13. import { RollAnnouncer } from "@/components/dice/roll-announcer";
  14. import { ListRollCarousel } from "@/components/dice/list-roll-carousel";
  15. import { GenreRollModal } from "@/components/dice/genre-roll-modal";
  16. import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
  17. import type { Database } from "@/types/database";
  18. type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
  19. interface MemberInfo {
  20. id: string;
  21. displayName: string;
  22. avatarColor: string | null;
  23. }
  24. interface MovieListClientProps {
  25. groupId: string;
  26. groupName: string;
  27. members: MemberInfo[];
  28. }
  29. export function MovieListClient({ groupId, members }: MovieListClientProps) {
  30. const [searchQuery, setSearchQuery] = useState("");
  31. const [resultsHidden, setResultsHidden] = useState(false);
  32. const [addingTmdbId, setAddingTmdbId] = useState<number | null>(null);
  33. const [genreModalOpen, setGenreModalOpen] = useState(false);
  34. const [noMatchesBanner, setNoMatchesBanner] = useState(false);
  35. const { data, isLoading } = useGroupMovies(groupId);
  36. const allMovies = useMemo(() => data?.pages.flat() ?? [], [data]);
  37. const unwatchedPool = useMemo(() => allMovies.filter((m) => !m.watched), [allMovies]);
  38. const poolEmpty = unwatchedPool.length === 0;
  39. const { data: searchData, isLoading: isSearchLoading } = useMovieSearch(searchQuery);
  40. const tmdbResults = searchData?.results ?? [];
  41. const addMovie = useAddMovie();
  42. const toggleWatched = useToggleWatched(groupId);
  43. const { result: winner, rollState, roll } = useRoll();
  44. useRealtimeMovies(groupId);
  45. const users = useMemo(
  46. () => members.map((m) => ({ id: m.id, avatar_color: m.avatarColor })),
  47. [members],
  48. );
  49. const userNames = useMemo(() => {
  50. const m = new Map<string, string>();
  51. for (const member of members) m.set(member.id, member.displayName);
  52. return m;
  53. }, [members]);
  54. const handleSearch = useCallback((query: string) => {
  55. setSearchQuery(query);
  56. setResultsHidden(false);
  57. }, []);
  58. const handleAdd = useCallback(
  59. (tmdbId: number) => {
  60. setAddingTmdbId(tmdbId);
  61. setResultsHidden(true);
  62. addMovie.mutate(
  63. { tmdb_id: tmdbId, group_id: groupId },
  64. {
  65. onSettled: () => setAddingTmdbId(null),
  66. },
  67. );
  68. },
  69. [addMovie, groupId],
  70. );
  71. const handleRandomRoll = useCallback(() => {
  72. if (isLoading || poolEmpty) return;
  73. setNoMatchesBanner(false);
  74. roll(unwatchedPool);
  75. }, [isLoading, poolEmpty, unwatchedPool, roll]);
  76. const handleGenreRollOpen = useCallback(() => {
  77. if (isLoading || poolEmpty) return;
  78. setGenreModalOpen(true);
  79. }, [isLoading, poolEmpty]);
  80. const handleGenreRollSubmit = useCallback(
  81. (payload: { genreIds: number[]; moodKeys: string[] }) => {
  82. setGenreModalOpen(false);
  83. const { movies: filtered, noMatches } = filterByGenresAndEmotionsStructured(
  84. payload,
  85. unwatchedPool,
  86. );
  87. if (noMatches) {
  88. setNoMatchesBanner(true);
  89. roll(unwatchedPool);
  90. return;
  91. }
  92. setNoMatchesBanner(false);
  93. roll(filtered);
  94. },
  95. [unwatchedPool, roll],
  96. );
  97. const handleWatchedToggle = useCallback(
  98. (movie: MovieRow) => {
  99. toggleWatched.mutate({ movieId: movie.id, watched: !movie.watched });
  100. },
  101. [toggleWatched],
  102. );
  103. return (
  104. <div className="space-y-6">
  105. <RollBar
  106. isLoading={isLoading}
  107. poolEmpty={poolEmpty}
  108. onRoll={handleRandomRoll}
  109. onGenreRoll={handleGenreRollOpen}
  110. />
  111. <div>
  112. <SearchBar onSearch={handleSearch} isLoading={isSearchLoading} />
  113. {addMovie.isError && (
  114. <p role="alert" aria-live="polite" className="mt-2 text-sm text-red-500">
  115. Couldn&apos;t add movie. Please try again.
  116. {addMovie.error?.message ? ` (${addMovie.error.message})` : ""}
  117. </p>
  118. )}
  119. {searchQuery.length >= 2 && !resultsHidden && (
  120. <div className="mt-2 rounded-lg border border-white/10 bg-white/5 p-3">
  121. <SearchResults
  122. tmdbResults={tmdbResults}
  123. groupMovies={allMovies}
  124. query={searchQuery}
  125. isAdding={addMovie.isPending}
  126. addingTmdbId={addingTmdbId}
  127. onAdd={handleAdd}
  128. />
  129. </div>
  130. )}
  131. </div>
  132. <RollAnnouncer state={rollState} winner={winner} />
  133. {noMatchesBanner && (
  134. <div
  135. role="status"
  136. aria-live="polite"
  137. className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-200"
  138. data-testid="roll-no-matches-banner"
  139. >
  140. No matches — showing full list
  141. </div>
  142. )}
  143. {rollState !== "idle" && (
  144. <ListRollCarousel
  145. pool={allMovies}
  146. winner={winner}
  147. state={rollState}
  148. onWatchedToggle={handleWatchedToggle}
  149. />
  150. )}
  151. <section aria-label="Movie list" aria-live="polite">
  152. <PosterGrid groupId={groupId} users={users} userNames={userNames} />
  153. </section>
  154. {genreModalOpen && (
  155. <GenreRollModal
  156. onClose={() => setGenreModalOpen(false)}
  157. onRoll={handleGenreRollSubmit}
  158. loading={false}
  159. />
  160. )}
  161. </div>
  162. );
  163. }