Ver Fonte

feat: add poster grid with infinite scroll and watched section

Implement Unit 8 — movie poster grid with TanStack Query infinite
pagination, IntersectionObserver-based auto-loading, and a collapsible
watched section. Cards show TMDB w342 posters, added-by avatar color,
and binoculars emoji for watched movies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User há 2 meses atrás
pai
commit
91f2a0d9e5

+ 58 - 0
src/components/movies/poster-card.tsx

@@ -0,0 +1,58 @@
+"use client";
+
+import type { Database } from "@/types/database";
+import { getTMDBImageUrl } from "@/types/tmdb";
+
+type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
+
+interface PosterCardProps {
+  movie: MovieRow;
+  avatarColor?: string | null;
+  onSelect: (movie: MovieRow) => void;
+}
+
+export function PosterCard({ movie, avatarColor, onSelect }: PosterCardProps) {
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "grid");
+  const altText = `${movie.title} (${movie.year}) poster`;
+
+  return (
+    <button
+      type="button"
+      onClick={() => onSelect(movie)}
+      className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded-lg"
+    >
+      <div className="relative aspect-[2/3] overflow-hidden rounded-lg bg-gray-800">
+        {posterUrl ? (
+          <img
+            src={posterUrl}
+            alt={altText}
+            loading="lazy"
+            className="h-full w-full object-cover"
+          />
+        ) : (
+          <div className="flex h-full w-full items-center justify-center p-2 text-center text-sm text-gray-400">
+            {movie.title}
+          </div>
+        )}
+
+        {movie.watched && (
+          <span
+            className="absolute top-1.5 left-1.5 text-lg leading-none"
+            aria-label="Watched"
+          >
+            🔭
+          </span>
+        )}
+
+        {avatarColor && (
+          <span
+            className="absolute top-1.5 right-1.5 block h-5 w-5 rounded-full border-2 border-white/80"
+            style={{ backgroundColor: avatarColor }}
+            aria-hidden="true"
+          />
+        )}
+      </div>
+      <p className="mt-1 truncate text-sm font-medium text-gray-100">{movie.title}</p>
+    </button>
+  );
+}

+ 128 - 0
src/components/movies/poster-grid.tsx

@@ -0,0 +1,128 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Database } from "@/types/database";
+import { useGroupMovies } from "@/hooks/use-group-movies";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { PosterCard } from "./poster-card";
+
+type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
+
+interface UserInfo {
+  id: string;
+  avatar_color: string | null;
+}
+
+interface PosterGridProps {
+  groupId: string;
+  users?: UserInfo[];
+  onSelect: (movie: MovieRow) => void;
+}
+
+export function PosterGrid({ groupId, users, onSelect }: PosterGridProps) {
+  const [watchedOpen, setWatchedOpen] = useState(false);
+
+  const { data, hasNextPage, isFetchingNextPage, fetchNextPage, isLoading, isError } =
+    useGroupMovies(groupId);
+
+  const sentinelRef = useInfiniteScroll({
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
+  });
+
+  const allMovies = useMemo(() => data?.pages.flat() ?? [], [data]);
+
+  const { unwatched, watched } = useMemo(() => {
+    const uw: MovieRow[] = [];
+    const w: MovieRow[] = [];
+    for (const movie of allMovies) {
+      if (movie.watched) w.push(movie);
+      else uw.push(movie);
+    }
+    return { unwatched: uw, watched: w };
+  }, [allMovies]);
+
+  const userMap = useMemo(() => {
+    const map = new Map<string, string | null>();
+    if (users) {
+      for (const u of users) {
+        map.set(u.id, u.avatar_color);
+      }
+    }
+    return map;
+  }, [users]);
+
+  function getAvatarColor(movie: MovieRow) {
+    return movie.added_by ? (userMap.get(movie.added_by) ?? null) : null;
+  }
+
+  if (isLoading) {
+    return <p className="py-8 text-center text-gray-400">Loading movies...</p>;
+  }
+
+  if (isError) {
+    return <p className="py-8 text-center text-red-400">Failed to load movies.</p>;
+  }
+
+  if (allMovies.length === 0) {
+    return <p className="py-8 text-center text-gray-400">No movies yet. Add one to get started!</p>;
+  }
+
+  return (
+    <div>
+      <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
+        {unwatched.map((movie) => (
+          <PosterCard
+            key={movie.id}
+            movie={movie}
+            avatarColor={getAvatarColor(movie)}
+            onSelect={onSelect}
+          />
+        ))}
+      </div>
+
+      <div ref={sentinelRef} className="h-1" />
+
+      {isFetchingNextPage && (
+        <p className="py-4 text-center text-sm text-gray-400">Loading more...</p>
+      )}
+
+      {watched.length > 0 && (
+        <div className="mt-6">
+          <button
+            type="button"
+            onClick={() => setWatchedOpen((prev) => !prev)}
+            className="flex items-center gap-1.5 text-sm font-medium text-gray-400 hover:text-gray-200"
+            aria-expanded={watchedOpen}
+          >
+            <span
+              className="inline-block transition-transform"
+              style={{ transform: watchedOpen ? "rotate(90deg)" : "rotate(0deg)" }}
+              aria-hidden="true"
+            >
+              ▶
+            </span>
+            Watched ({watched.length})
+          </button>
+
+          {watchedOpen && (
+            <div
+              className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
+              aria-live="polite"
+            >
+              {watched.map((movie) => (
+                <PosterCard
+                  key={movie.id}
+                  movie={movie}
+                  avatarColor={getAvatarColor(movie)}
+                  onSelect={onSelect}
+                />
+              ))}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 36 - 0
src/hooks/use-group-movies.ts

@@ -0,0 +1,36 @@
+"use client";
+
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { MOVIES_PER_PAGE } from "@/lib/constants";
+import type { Database } from "@/types/database";
+
+type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
+
+async function fetchGroupMovies(groupId: string, page: number): Promise<MovieRow[]> {
+  const supabase = getSupabaseBrowserClient();
+  const from = page * MOVIES_PER_PAGE;
+  const to = from + MOVIES_PER_PAGE - 1;
+
+  const { data, error } = await supabase
+    .from("movies")
+    .select("*")
+    .eq("group_id", groupId)
+    .order("added_at", { ascending: false })
+    .range(from, to);
+
+  if (error) throw error;
+  return data ?? [];
+}
+
+export function useGroupMovies(groupId: string) {
+  return useInfiniteQuery({
+    queryKey: ["group-movies", groupId],
+    queryFn: ({ pageParam }) => fetchGroupMovies(groupId, pageParam),
+    initialPageParam: 0,
+    getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+      lastPage.length === MOVIES_PER_PAGE ? lastPageParam + 1 : undefined,
+    staleTime: 2 * 60 * 1000,
+    enabled: !!groupId,
+  });
+}

+ 38 - 0
src/hooks/use-infinite-scroll.ts

@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+interface UseInfiniteScrollOptions {
+  hasNextPage: boolean | undefined;
+  isFetchingNextPage: boolean;
+  fetchNextPage: () => void;
+  rootMargin?: string;
+}
+
+export function useInfiniteScroll({
+  hasNextPage,
+  isFetchingNextPage,
+  fetchNextPage,
+  rootMargin = "200px",
+}: UseInfiniteScrollOptions) {
+  const sentinelRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const sentinel = sentinelRef.current;
+    if (!sentinel) return;
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
+          fetchNextPage();
+        }
+      },
+      { rootMargin },
+    );
+
+    observer.observe(sentinel);
+    return () => observer.disconnect();
+  }, [hasNextPage, isFetchingNextPage, fetchNextPage, rootMargin]);
+
+  return sentinelRef;
+}