Pārlūkot izejas kodu

[UI] List detail, settings, roll modals; surface add-movie errors inline

Adds ListMoreInfoModal + ListRollCarousel (whole-card click target, dice-
emerge entrance, snap-to-gap with ±110px spread). List header centered
stack with join-code chip + settings cog. Settings page mounts SettingsPanel
with shake-to-arm destructive actions. JoinListButton on home. Hooks updated
for new auth boundary. useAddMovie surfaces inline role="alert" errors via
movie-list-client (matches existing mutation.error precedent; no toast lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 mēnesi atpakaļ
vecāks
revīzija
680cafb3a8

+ 27 - 14
src/__tests__/hooks/use-all-user-movies.test.ts

@@ -1,7 +1,7 @@
 /**
  * @vitest-environment jsdom
  */
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import { renderHook, waitFor } from "@testing-library/react";
 import React from "react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -37,15 +37,35 @@ const fromMock = vi.fn(() => makeQueryBuilder());
 vi.mock("@/lib/supabase/client", () => ({
   getSupabaseBrowserClient: () => ({
     from: fromMock,
-    auth: {
-      getUser: async () => ({ data: { user: mockUserId ? { id: mockUserId } : null } }),
-      onAuthStateChange: () => ({
-        data: { subscription: { unsubscribe: () => {} } },
-      }),
-    },
   }),
 }));
 
+const originalFetch = globalThis.fetch;
+
+beforeEach(() => {
+  calls.length = 0;
+  fromMock.mockClear();
+  mockRows = [];
+  mockUserId = "user-1";
+  globalThis.fetch = vi.fn(async (input: unknown) => {
+    const url = typeof input === "string" ? input : ((input as { url?: string }).url ?? "");
+    if (url.includes("/api/auth/me")) {
+      if (!mockUserId) {
+        return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
+      }
+      return new Response(JSON.stringify({ id: mockUserId, isAnonymous: true }), {
+        status: 200,
+        headers: { "Content-Type": "application/json" },
+      });
+    }
+    throw new Error(`Unexpected fetch in test: ${url}`);
+  }) as typeof fetch;
+});
+
+afterEach(() => {
+  globalThis.fetch = originalFetch;
+});
+
 import { useAllUserMovies } from "@/hooks/use-all-user-movies";
 
 function wrapper({ children }: { children: React.ReactNode }) {
@@ -55,13 +75,6 @@ function wrapper({ children }: { children: React.ReactNode }) {
   return React.createElement(QueryClientProvider, { client }, children);
 }
 
-beforeEach(() => {
-  calls.length = 0;
-  fromMock.mockClear();
-  mockRows = [];
-  mockUserId = "user-1";
-});
-
 describe("useAllUserMovies", () => {
   it("queries movies via the browser client and never filters by a client-supplied group list", async () => {
     mockRows = [

+ 28 - 27
src/__tests__/hooks/use-realtime-movies.test.ts

@@ -1,11 +1,9 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import { renderHook, waitFor } from "@testing-library/react";
 import React from "react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
-let lastOnPayload:
-  | ((payload: Record<string, unknown>) => void)
-  | null = null;
+let lastOnPayload: ((payload: Record<string, unknown>) => void) | null = null;
 
 vi.mock("@/hooks/use-realtime-channel", () => ({
   useRealtimeChannel: (opts: {
@@ -18,16 +16,22 @@ vi.mock("@/hooks/use-realtime-channel", () => ({
 
 const TEST_USER_ID = "user-abc-123";
 
-vi.mock("@/lib/supabase/client", () => ({
-  getSupabaseBrowserClient: () => ({
-    auth: {
-      getUser: vi.fn().mockResolvedValue({
-        data: { user: { id: TEST_USER_ID } },
-        error: null,
-      }),
-    },
-  }),
-}));
+// useRealtimeMovies now resolves the current user via useCurrentUser(),
+// which fetches /api/auth/me. Mock fetch to return the test user.
+const originalFetch = globalThis.fetch;
+beforeEach(() => {
+  lastOnPayload = null;
+  globalThis.fetch = vi.fn(async (input: unknown) => {
+    const url = typeof input === "string" ? input : ((input as { url?: string }).url ?? "");
+    if (url.includes("/api/auth/me")) {
+      return new Response(JSON.stringify({ id: TEST_USER_ID, isAnonymous: true }), {
+        status: 200,
+        headers: { "Content-Type": "application/json" },
+      });
+    }
+    throw new Error(`Unexpected fetch in test: ${url}`);
+  }) as typeof fetch;
+});
 
 import { useRealtimeMovies } from "@/hooks/use-realtime-movies";
 
@@ -53,6 +57,11 @@ function makeWrapper() {
     pages: [[makeMovie("seed-1")]],
     pageParams: [0],
   });
+  // Pre-seed current-user so the realtime hook sees a userId on first render.
+  queryClient.setQueryData(["current-user"], {
+    id: TEST_USER_ID,
+    isAnonymous: true,
+  });
   const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
   const wrapper = ({ children }: { children: React.ReactNode }) =>
     React.createElement(QueryClientProvider, { client: queryClient }, children);
@@ -60,20 +69,12 @@ function makeWrapper() {
 }
 
 describe("useRealtimeMovies — dual invalidation of all-user-movies", () => {
-  beforeEach(() => {
-    lastOnPayload = null;
-  });
-
   async function renderAndWaitForUser() {
     const { wrapper, queryClient, invalidateSpy } = makeWrapper();
     const hook = renderHook(() => useRealtimeMovies(GROUP_ID), { wrapper });
     await waitFor(() => {
       expect(lastOnPayload).not.toBeNull();
     });
-    // Flush microtasks so the auth.getUser() promise resolves and the
-    // userId ref is populated before the test fires a realtime payload.
-    await Promise.resolve();
-    await Promise.resolve();
     return { hook, queryClient, invalidateSpy };
   }
 
@@ -81,11 +82,7 @@ describe("useRealtimeMovies — dual invalidation of all-user-movies", () => {
     return spy.mock.calls.some((call) => {
       const arg = call[0] as { queryKey?: unknown[] } | undefined;
       const key = arg?.queryKey;
-      return (
-        Array.isArray(key) &&
-        key[0] === "all-user-movies" &&
-        key[1] === TEST_USER_ID
-      );
+      return Array.isArray(key) && key[0] === "all-user-movies" && key[1] === TEST_USER_ID;
     });
   }
 
@@ -140,3 +137,7 @@ describe("useRealtimeMovies — dual invalidation of all-user-movies", () => {
     expect(cached?.pages[0].some((m) => m.id === "new-2")).toBe(true);
   });
 });
+
+afterEach(() => {
+  globalThis.fetch = originalFetch;
+});

+ 5 - 12
src/app/(app)/home/page.tsx

@@ -1,6 +1,5 @@
 "use client";
 
-import Link from "next/link";
 import { ListGrid } from "@/components/home/list-grid";
 import { RollSection } from "@/components/home/roll-section";
 
@@ -8,18 +7,12 @@ export default function HomePage() {
   return (
     <div className="mx-auto max-w-3xl px-4 py-8">
       <section className="mb-8">
-        <h1 className="text-2xl font-bold text-foreground">Your Lists</h1>
-        <p className="mt-1 text-sm text-foreground/60">Pick a list or roll across all of them.</p>
-        <div className="mt-4 flex flex-col gap-4">
+        <h1 className="text-2xl font-bold text-foreground text-center sm:text-left">Your Lists</h1>
+        <p className="mt-1 text-sm text-foreground/60 text-center sm:text-left">
+          Pick a list or roll across all of them.
+        </p>
+        <div className="mt-4">
           <RollSection />
-          <div>
-            <Link
-              href="/create"
-              className="inline-block rounded-lg bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
-            >
-              + Create List
-            </Link>
-          </div>
         </div>
       </section>
 

+ 16 - 6
src/app/(app)/list/[id]/page.tsx

@@ -10,18 +10,22 @@ export default async function ListPage({ params }: ListPageProps) {
   const { id } = await params;
   const supabase = await getSupabaseServerClient();
 
-  const { data } = await supabase.from("groups").select("id, name").eq("id", id).single();
+  const { data } = await supabase
+    .from("groups")
+    .select("id, name, invite_code")
+    .eq("id", id)
+    .single();
 
   if (!data) {
     notFound();
   }
 
-  const group = data as { id: string; name: string };
+  const group = data as { id: string; name: string; invite_code: string };
 
-  const { data: membersData } = await supabase
+  const { data: membersData } = (await supabase
     .from("group_members")
     .select("user_id, users(display_name, avatar_color)")
-    .eq("group_id", id) as {
+    .eq("group_id", id)) as {
     data:
       | { user_id: string; users: { display_name: string; avatar_color: string | null } | null }[]
       | null;
@@ -37,8 +41,14 @@ export default async function ListPage({ params }: ListPageProps) {
     <div className="flex min-h-screen flex-col">
       {/* Header */}
       <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
-        <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
-          <h1 className="truncate text-xl font-bold">{group.name}</h1>
+        <div className="mx-auto flex max-w-5xl flex-col items-center gap-2 px-4 py-4 text-center">
+          <h1 className="max-w-full truncate text-2xl font-bold sm:text-3xl">{group.name}</h1>
+          <p className="text-[11px] uppercase tracking-[0.18em] text-foreground/45">
+            Join code{" "}
+            <span className="ml-1 rounded bg-foreground/10 px-2 py-0.5 font-mono text-xs tracking-normal text-foreground/80">
+              {group.invite_code}
+            </span>
+          </p>
           <a
             href={`/list/${group.id}/settings`}
             className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"

+ 20 - 0
src/app/(app)/list/[id]/settings/page.tsx

@@ -0,0 +1,20 @@
+import { SettingsPanel } from "@/components/groups/settings-panel";
+
+interface SettingsPageProps {
+  params: Promise<{ id: string }>;
+}
+
+export default async function ListSettingsPage({ params }: SettingsPageProps) {
+  const { id } = await params;
+  return (
+    <main className="mx-auto w-full max-w-3xl px-4 py-6">
+      <div className="mb-6 flex items-center justify-between">
+        <h1 className="text-2xl font-bold">List Settings</h1>
+        <a href={`/list/${id}`} className="text-sm text-foreground/60 hover:text-foreground">
+          ← Back to list
+        </a>
+      </div>
+      <SettingsPanel groupId={id} />
+    </main>
+  );
+}

+ 270 - 0
src/components/dice/list-more-info-modal.tsx

@@ -0,0 +1,270 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import type { Movie } from "@/types/movie";
+import { getTMDBImageUrl } from "@/types/tmdb";
+
+interface ListMoreInfoModalProps {
+  movie: Movie;
+  onClose: () => void;
+  onWatchedToggle: (movie: Movie) => void;
+  /** Optional: when present, renders a "Delete Movie" button with two-click confirm. */
+  onDelete?: (movie: Movie) => void;
+  /** Optional: name of the user who added this movie. */
+  addedByName?: string | null;
+}
+
+export function ListMoreInfoModal({
+  movie,
+  onClose,
+  onWatchedToggle,
+  onDelete,
+  addedByName,
+}: ListMoreInfoModalProps) {
+  const [mounted, setMounted] = useState(false);
+  const [overview, setOverview] = useState<string | null>(null);
+  // Local watched state — keeps the modal display in sync with the user's
+  // confirmed clicks even before the upstream cache invalidates. Initialized
+  // from the prop and flipped on confirm.
+  const [localWatched, setLocalWatched] = useState(movie.watched);
+  const [watchedConfirm, setWatchedConfirm] = useState(false);
+  const [deleteConfirm, setDeleteConfirm] = useState(false);
+  const dialogRef = useRef<HTMLDivElement>(null);
+  const closeBtnRef = useRef<HTMLButtonElement>(null);
+  // Auto-disarm timers — match the SettingsPanel shake-to-arm pattern (4s).
+  const watchedDisarmRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const deleteDisarmRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const armWatched = useCallback(() => {
+    setWatchedConfirm(true);
+    setDeleteConfirm(false);
+    if (deleteDisarmRef.current) clearTimeout(deleteDisarmRef.current);
+    if (watchedDisarmRef.current) clearTimeout(watchedDisarmRef.current);
+    watchedDisarmRef.current = setTimeout(() => setWatchedConfirm(false), 4000);
+  }, []);
+
+  const armDelete = useCallback(() => {
+    setDeleteConfirm(true);
+    setWatchedConfirm(false);
+    if (watchedDisarmRef.current) clearTimeout(watchedDisarmRef.current);
+    if (deleteDisarmRef.current) clearTimeout(deleteDisarmRef.current);
+    deleteDisarmRef.current = setTimeout(() => setDeleteConfirm(false), 4000);
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      if (watchedDisarmRef.current) clearTimeout(watchedDisarmRef.current);
+      if (deleteDisarmRef.current) clearTimeout(deleteDisarmRef.current);
+    };
+  }, []);
+
+  useEffect(() => {
+    // eslint-disable-next-line react-hooks/set-state-in-effect -- defer portal until client mount; SSR-safe pattern
+    setMounted(true);
+  }, []);
+
+  useEffect(() => {
+    let cancelled = false;
+    fetch(`/api/tmdb/movie/${movie.tmdb_id}`)
+      .then((res) => (res.ok ? res.json() : null))
+      .then((data: { overview?: string } | null) => {
+        if (!cancelled && data?.overview) setOverview(data.overview);
+      })
+      .catch(() => {});
+    return () => {
+      cancelled = true;
+    };
+  }, [movie.tmdb_id]);
+
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
+
+  useEffect(() => {
+    const previouslyFocused = document.activeElement as HTMLElement | null;
+    closeBtnRef.current?.focus();
+
+    function handleKey(e: KeyboardEvent) {
+      if (e.key === "Escape") {
+        e.stopPropagation();
+        onClose();
+      }
+      if (e.key === "Tab" && dialogRef.current) {
+        const focusables = Array.from(
+          dialogRef.current.querySelectorAll<HTMLElement>(
+            'button, a[href], [tabindex]:not([tabindex="-1"])',
+          ),
+        ).filter((el) => !el.hasAttribute("disabled"));
+        if (focusables.length === 0) return;
+        const first = focusables[0];
+        const last = focusables[focusables.length - 1];
+        if (e.shiftKey && document.activeElement === first) {
+          e.preventDefault();
+          last.focus();
+        } else if (!e.shiftKey && document.activeElement === last) {
+          e.preventDefault();
+          first.focus();
+        }
+      }
+    }
+
+    document.addEventListener("keydown", handleKey);
+    return () => {
+      document.removeEventListener("keydown", handleKey);
+      previouslyFocused?.focus?.();
+    };
+  }, [onClose]);
+
+  if (!mounted) return null;
+
+  // Watched button two-click confirm.
+  // - localWatched=false, idle:        "Watched It"      (gray)
+  // - localWatched=false, confirming:  "Click to confirm" (green)  ← color of destination
+  // - localWatched=true,  idle:        "Watched"          (green)
+  // - localWatched=true,  confirming:  "Unwatch Movie"    (gray)   ← color of destination
+  function handleWatchedClick() {
+    if (!watchedConfirm) {
+      armWatched();
+      return;
+    }
+    if (watchedDisarmRef.current) clearTimeout(watchedDisarmRef.current);
+    onWatchedToggle(movie);
+    setLocalWatched((w) => !w);
+    setWatchedConfirm(false);
+  }
+
+  function handleDeleteClick() {
+    if (!deleteConfirm) {
+      armDelete();
+      return;
+    }
+    if (deleteDisarmRef.current) clearTimeout(deleteDisarmRef.current);
+    onDelete?.(movie);
+    setDeleteConfirm(false);
+    onClose();
+  }
+
+  const watchedLabel = watchedConfirm
+    ? localWatched
+      ? "Unwatch Movie"
+      : "Click to confirm"
+    : localWatched
+      ? "Watched"
+      : "Watched It";
+
+  // Color = destination state when confirming, else current state.
+  const watchedShowsGreen = watchedConfirm ? !localWatched : localWatched;
+  const watchedClasses = watchedShowsGreen
+    ? "bg-green-600 text-white hover:bg-green-700"
+    : "bg-foreground/10 text-foreground hover:bg-foreground/20";
+
+  const deleteLabel = deleteConfirm ? "Click to confirm" : "Delete Movie";
+
+  return createPortal(
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
+      onClick={onClose}
+    >
+      <div
+        ref={dialogRef}
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="list-more-info-title"
+        className="relative flex max-h-[92vh] w-full max-w-5xl flex-col gap-6 overflow-y-auto rounded-2xl bg-background p-6 shadow-2xl sm:flex-row sm:gap-8 sm:p-8 lg:max-w-6xl"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <button
+          ref={closeBtnRef}
+          onClick={onClose}
+          aria-label="Close"
+          className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-foreground/5 text-xl text-foreground/70 transition-colors hover:bg-foreground/15 hover:text-foreground"
+        >
+          ×
+        </button>
+
+        {posterUrl && (
+          /* eslint-disable-next-line @next/next/no-img-element */
+          <img
+            src={posterUrl}
+            alt={`Poster for ${movie.title}`}
+            loading="lazy"
+            className="mx-auto h-auto w-48 flex-shrink-0 rounded-xl shadow-lg sm:w-64"
+          />
+        )}
+
+        <div className="flex min-w-0 flex-1 flex-col">
+          <h2 id="list-more-info-title" className="pr-12 text-3xl font-semibold leading-tight">
+            {movie.title}
+            {movie.year > 0 && (
+              <span className="ml-2 text-xl font-normal text-foreground/60">({movie.year})</span>
+            )}
+          </h2>
+
+          {movie.genres.length > 0 && (
+            <div className="mt-3 flex flex-wrap gap-2">
+              {movie.genres.slice(0, 4).map((genre) => (
+                <span
+                  key={genre}
+                  className="rounded-full bg-foreground/10 px-3 py-1 text-sm text-foreground/70"
+                >
+                  {genre}
+                </span>
+              ))}
+            </div>
+          )}
+
+          {addedByName && <p className="mt-3 text-sm text-foreground/60">Added by {addedByName}</p>}
+
+          {overview && (
+            <p className="mt-5 text-base leading-relaxed text-foreground/85">{overview}</p>
+          )}
+
+          <div className="mt-auto flex flex-col gap-3 pt-6 sm:flex-row">
+            <button
+              type="button"
+              onClick={handleWatchedClick}
+              className={`flex-1 rounded-lg py-3 text-center text-base font-semibold transition-colors ${watchedClasses} ${
+                watchedConfirm ? "animate-shake" : ""
+              }`}
+            >
+              {watchedLabel}
+            </button>
+            {movie.trailer_url ? (
+              <a
+                href={movie.trailer_url}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="flex-1 rounded-lg border border-foreground/30 py-3 text-center text-base font-semibold transition-colors hover:bg-foreground/5"
+              >
+                Watch Trailer
+              </a>
+            ) : (
+              <button
+                disabled
+                className="flex-1 rounded-lg border border-foreground/20 py-3 text-base font-semibold text-foreground/40"
+              >
+                No trailer
+              </button>
+            )}
+          </div>
+
+          {onDelete && (
+            <div className="pt-3">
+              <button
+                type="button"
+                onClick={handleDeleteClick}
+                className={`w-full rounded-lg py-3 text-center text-base font-semibold text-white transition-colors ${
+                  deleteConfirm
+                    ? "animate-shake bg-red-700 hover:bg-red-800"
+                    : "bg-red-600 hover:bg-red-700"
+                }`}
+              >
+                {deleteLabel}
+              </button>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>,
+    document.body,
+  );
+}

+ 280 - 0
src/components/dice/list-roll-carousel.tsx

@@ -0,0 +1,280 @@
+"use client";
+
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { getTMDBImageUrl } from "@/types/tmdb";
+import type { Movie } from "@/types/movie";
+import type { RollState } from "@/hooks/use-roll";
+import { ListMoreInfoModal } from "./list-more-info-modal";
+
+const ITEM_WIDTH = 112; // w-28
+const ITEM_GAP = 12; // gap-3
+const POSTER_STRIDE = ITEM_WIDTH + ITEM_GAP;
+// Carousel pops in via the dice-emerge keyframe (~500ms scale-bounce), then
+// pauses briefly, then spins. useRoll's own 2500ms timer governs `state`
+// transitions but doesn't gate the visual settle — the carousel owns its
+// timeline and runs slightly longer for a punchier feel.
+const ENTRANCE_MS = 500; // matches @utility animate-emerge duration
+const PRE_SPIN_PAUSE_MS = 100;
+const SPIN_DURATION_MS = 2750;
+const FAST_SPEED = 38;
+const SPREAD_AMOUNT = 110;
+const MIN_TILE_COUNT = 10;
+
+type Phase = "entrance" | "spinning" | "settled";
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeRM(cb: () => void) {
+  const mq = window.matchMedia(REDUCED_MOTION_QUERY);
+  mq.addEventListener("change", cb);
+  return () => mq.removeEventListener("change", cb);
+}
+function rmSnapshot() {
+  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
+}
+function rmServerSnapshot() {
+  return false;
+}
+function usePrefersReducedMotion() {
+  return useSyncExternalStore(subscribeRM, rmSnapshot, rmServerSnapshot);
+}
+
+interface ListRollCarouselProps {
+  /**
+   * Pool of movies to *display* in the strip. Pass the full list so watched
+   * movies still appear visually — winner-eligibility is decided upstream by
+   * useRoll, not here.
+   */
+  pool: Movie[];
+  winner: Movie | null;
+  state: RollState;
+  onWatchedToggle: (movie: Movie) => void;
+}
+
+export function ListRollCarousel({ pool, winner, state, onWatchedToggle }: ListRollCarouselProps) {
+  const prefersReducedMotion = usePrefersReducedMotion();
+  const [phase, setPhase] = useState<Phase>("entrance");
+  const [viewportWidth, setViewportWidth] = useState(0);
+
+  const stripRef = useRef<HTMLDivElement>(null);
+  const viewportRef = useRef<HTMLDivElement>(null);
+  const animRef = useRef<number | null>(null);
+  const offsetRef = useRef(0);
+  const spinStartRef = useRef(0);
+
+  // Tile the pool so we always have enough posters for a smooth continuous
+  // scroll (landing carousel uses tripled tiling with modular wraparound).
+  const tiled = (() => {
+    if (!pool.length) return [];
+    const out: Movie[] = [];
+    while (out.length < MIN_TILE_COUNT) {
+      for (const m of pool) {
+        out.push(m);
+        if (out.length >= MIN_TILE_COUNT && out.length % pool.length === 0) break;
+      }
+    }
+    return out;
+  })();
+  const tripled = tiled.length > 0 ? [...tiled, ...tiled, ...tiled] : [];
+  const SET_WIDTH = tiled.length * POSTER_STRIDE;
+
+  // Measure viewport width for spread/center math.
+  useEffect(() => {
+    if (!viewportRef.current) return;
+    const measure = () => {
+      if (viewportRef.current) setViewportWidth(viewportRef.current.offsetWidth);
+    };
+    measure();
+    const ro = new ResizeObserver(measure);
+    ro.observe(viewportRef.current);
+    return () => ro.disconnect();
+  }, []);
+
+  // Reset to entrance phase whenever a new roll starts. The spin kicks off
+  // after ENTRANCE_MS + PRE_SPIN_PAUSE_MS, giving the carousel time to
+  // animate in before it starts moving.
+  useEffect(() => {
+    if (state === "rolling") {
+      setPhase("entrance");
+      spinStartRef.current = 0;
+      const t = setTimeout(() => setPhase("spinning"), ENTRANCE_MS + PRE_SPIN_PAUSE_MS);
+      return () => clearTimeout(t);
+    }
+  }, [state]);
+
+  // Animation loop. Runs while we're in a non-idle state and not reduced-motion.
+  useEffect(() => {
+    if (state === "idle") return;
+    if (!tiled.length) return;
+    if (prefersReducedMotion) {
+      // Skip animation entirely — settle immediately so the teaser emerges.
+      setPhase("settled");
+      return;
+    }
+    if (phase !== "spinning") return;
+
+    function applyTransform() {
+      if (stripRef.current) {
+        stripRef.current.style.transform = `translateX(-${offsetRef.current}px)`;
+      }
+    }
+
+    function tick(ts: number) {
+      if (spinStartRef.current === 0) spinStartRef.current = ts;
+      const elapsed = ts - spinStartRef.current;
+
+      if (elapsed >= SPIN_DURATION_MS) {
+        // Snap so a gap between posters lands at viewport center — opens an
+        // even bracket for the teaser card to emerge into.
+        const halfVp = viewportRef.current ? viewportRef.current.offsetWidth / 2 : 0;
+        const gapMidOffset = ITEM_WIDTH + ITEM_GAP / 2;
+        const i = Math.round((offsetRef.current + halfVp - gapMidOffset) / POSTER_STRIDE);
+        const snapped = i * POSTER_STRIDE + gapMidOffset - halfVp;
+        offsetRef.current = ((snapped % SET_WIDTH) + SET_WIDTH) % SET_WIDTH;
+        applyTransform();
+        setPhase("settled");
+        return;
+      }
+
+      const progress = elapsed / SPIN_DURATION_MS;
+      const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
+      const speed = FAST_SPEED * (1 - eased);
+      offsetRef.current += speed;
+      if (offsetRef.current >= SET_WIDTH) offsetRef.current -= SET_WIDTH;
+      applyTransform();
+
+      animRef.current = requestAnimationFrame(tick);
+    }
+
+    animRef.current = requestAnimationFrame(tick);
+    return () => {
+      if (animRef.current) cancelAnimationFrame(animRef.current);
+    };
+  }, [state, phase, prefersReducedMotion, tiled.length, SET_WIDTH]);
+
+  if (state === "idle") return null;
+  if (!tiled.length) return null;
+
+  const halfVp = viewportWidth / 2;
+  const showTeaser = phase === "settled" && winner;
+
+  return (
+    <div className="flex flex-col items-center gap-4">
+      <div className="relative flex min-h-[22rem] w-full items-center justify-center animate-emerge">
+        <div
+          ref={viewportRef}
+          className="absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden"
+        >
+          <div ref={stripRef} className="flex gap-3" aria-label="Movie poster carousel">
+            {/* eslint-disable-next-line react-hooks/refs -- offsetRef is stable in settled phase; rAF loop writes transform imperatively */}
+            {tripled.map((movie, i) => {
+              const url = getTMDBImageUrl(movie.poster_path, "reel");
+              const center = i * POSTER_STRIDE - offsetRef.current + ITEM_WIDTH / 2;
+              const spread =
+                showTeaser && halfVp > 0 ? (center < halfVp ? -SPREAD_AMOUNT : SPREAD_AMOUNT) : 0;
+              return (
+                <div
+                  key={`${movie.id}-${i}`}
+                  className="h-40 w-28 flex-shrink-0 transition-transform duration-500 ease-out"
+                  style={{ transform: `translateX(${spread}px)` }}
+                >
+                  {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 className="flex h-full w-full items-center justify-center rounded-lg bg-foreground/10 p-2 text-center text-xs text-foreground/60">
+                      {movie.title}
+                    </div>
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        </div>
+
+        <div
+          className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
+          aria-live="polite"
+        >
+          {showTeaser && (
+            <div className="pointer-events-auto animate-emerge">
+              <ListTeaserCard movie={winner} onWatchedToggle={onWatchedToggle} />
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ListTeaserCard({
+  movie,
+  onWatchedToggle,
+}: {
+  movie: Movie;
+  onWatchedToggle: (movie: Movie) => void;
+}) {
+  const [modalOpen, setModalOpen] = useState(false);
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "grid");
+
+  return (
+    <>
+      <div
+        className="flex w-44 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur"
+        aria-live="polite"
+      >
+        {posterUrl ? (
+          /* eslint-disable-next-line @next/next/no-img-element */
+          <img
+            src={posterUrl}
+            alt={`Movie poster for ${movie.title}`}
+            loading="lazy"
+            className="h-auto w-36 rounded-lg"
+          />
+        ) : (
+          <div className="flex h-52 w-36 items-center justify-center rounded-lg bg-foreground/10 text-xs text-foreground/40">
+            No poster
+          </div>
+        )}
+        <h3 className="mt-2 text-center text-sm font-semibold leading-tight line-clamp-2">
+          {movie.title}
+          {movie.year > 0 && (
+            <span className="ml-1 text-xs font-normal text-foreground/50">({movie.year})</span>
+          )}
+        </h3>
+        <button
+          onClick={() => setModalOpen(true)}
+          aria-label={`More info about ${movie.title}`}
+          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
+        >
+          i
+        </button>
+        {movie.genres.length > 0 && (
+          <div className="mt-1.5 flex flex-wrap justify-center gap-1">
+            {movie.genres.slice(0, 2).map((genre) => (
+              <span
+                key={genre}
+                className="rounded-full bg-foreground/10 px-2 py-0.5 text-[10px] text-foreground/70"
+              >
+                {genre}
+              </span>
+            ))}
+          </div>
+        )}
+      </div>
+      {modalOpen && (
+        <ListMoreInfoModal
+          movie={movie}
+          onClose={() => setModalOpen(false)}
+          onWatchedToggle={onWatchedToggle}
+        />
+      )}
+    </>
+  );
+}

+ 2 - 2
src/components/dice/roll-bar.tsx

@@ -34,14 +34,14 @@ export function RollBar({ isLoading, poolEmpty, onRoll, onGenreRoll }: RollBarPr
   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";
+    "inline-flex min-h-[44px] w-full sm:flex-1 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"
+      className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"
       aria-label="Randomizer actions"
       data-testid="roll-bar"
     >

+ 6 - 11
src/components/groups/create-group-form.tsx

@@ -1,12 +1,14 @@
 "use client";
 
 import { useState } from "react";
+import { useRouter } from "next/navigation";
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 import { GROUP_NAME_MAX_LENGTH } from "@/lib/constants";
 
 export function CreateGroupForm() {
   const [name, setName] = useState("");
   const queryClient = useQueryClient();
+  const router = useRouter();
 
   const createGroup = useMutation({
     mutationFn: async (groupName: string) => {
@@ -21,9 +23,11 @@ export function CreateGroupForm() {
       }
       return res.json();
     },
-    onSuccess: () => {
+    onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: ["groups"] });
       setName("");
+      const id = data?.group?.id;
+      if (id) router.push(`/list/${id}`);
     },
   });
 
@@ -61,18 +65,9 @@ export function CreateGroupForm() {
         </p>
       )}
 
-      {createGroup.isSuccess && createGroup.data?.group && (
-        <div className="rounded-md bg-green-50 p-3 dark:bg-green-900/20" role="alert">
-          <p className="text-sm font-medium text-green-800 dark:text-green-200">
-            Group created! Invite code:{" "}
-            <span className="font-mono font-bold">{createGroup.data.group.invite_code}</span>
-          </p>
-        </div>
-      )}
-
       <button
         type="submit"
-        disabled={createGroup.isPending || !name.trim()}
+        disabled={createGroup.isPending || createGroup.isSuccess || !name.trim()}
         className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
       >
         {createGroup.isPending ? "Creating..." : "Create Group"}

+ 158 - 16
src/components/groups/settings-panel.tsx

@@ -1,6 +1,7 @@
 "use client";
 
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { MemberList } from "./member-list";
 import { TransferOwnershipModal } from "./transfer-ownership-modal";
@@ -17,14 +18,40 @@ interface GroupData {
   role: "admin" | "member";
 }
 
+interface Member {
+  user_id: string;
+  role: "admin" | "member";
+  users: { display_name: string; avatar_color: string | null } | null;
+}
+
+interface MembersResponse {
+  members: Member[];
+  currentUserRole: "admin" | "member";
+}
+
 export function SettingsPanel({ groupId }: { groupId: string }) {
   const queryClient = useQueryClient();
+  const router = useRouter();
   const [newName, setNewName] = useState("");
   const [copied, setCopied] = useState(false);
   const [transferTarget, setTransferTarget] = useState<{
     userId: string;
     displayName: string;
   } | null>(null);
+  const [deleteState, setDeleteState] = useState<"idle" | "armed" | "choosing">("idle");
+  const disarmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const arm = useCallback(() => {
+    setDeleteState("armed");
+    if (disarmTimer.current) clearTimeout(disarmTimer.current);
+    disarmTimer.current = setTimeout(() => setDeleteState("idle"), 4000);
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      if (disarmTimer.current) clearTimeout(disarmTimer.current);
+    };
+  }, []);
 
   const { data, isLoading, error } = useQuery<GroupData>({
     queryKey: ["groups", groupId],
@@ -82,6 +109,7 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
     },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ["groups"] });
+      router.push("/");
     },
   });
 
@@ -98,9 +126,48 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
     },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ["groups"] });
+      router.push("/");
     },
   });
 
+  const transferAndLeave = useMutation({
+    mutationFn: async (newAdminId: string) => {
+      const tRes = await fetch(`/api/groups/${groupId}/transfer`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ new_admin_id: newAdminId }),
+      });
+      if (!tRes.ok) {
+        const body = await tRes.json();
+        throw new Error(body.error || "Failed to transfer ownership");
+      }
+      const lRes = await fetch(`/api/groups/${groupId}/leave`, { method: "POST" });
+      if (!lRes.ok) {
+        const body = await lRes.json();
+        throw new Error(body.error || "Failed to leave group");
+      }
+      return lRes.json();
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["groups"] });
+      router.push("/");
+    },
+  });
+
+  const membersQuery = useQuery<MembersResponse>({
+    queryKey: ["groups", groupId, "members"],
+    queryFn: async () => {
+      const res = await fetch(`/api/groups/${groupId}/members`);
+      if (!res.ok) throw new Error("Failed to fetch members");
+      return res.json();
+    },
+    staleTime: 30_000,
+  });
+
+  const memberCount = membersQuery.data?.members.length ?? 0;
+  const successors = (membersQuery.data?.members ?? []).filter((m) => m.role !== "admin");
+  const isSoloAdmin = memberCount <= 1;
+
   const inviteCode = data?.group.invite_code;
   const handleCopyCode = useCallback(async () => {
     if (!inviteCode) return;
@@ -193,17 +260,80 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
       <div className="border-t border-gray-200 pt-4 dark:border-gray-700">
         {isAdmin ? (
           <div className="space-y-2">
-            <button
-              onClick={() => {
-                if (confirm("Are you sure you want to delete this group? This cannot be undone.")) {
-                  deleteGroup.mutate();
-                }
-              }}
-              disabled={deleteGroup.isPending}
-              className="w-full rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
-            >
-              {deleteGroup.isPending ? "Deleting..." : "Delete Group"}
-            </button>
+            {deleteState !== "choosing" && (
+              <button
+                onClick={() => {
+                  if (deleteGroup.isPending || transferAndLeave.isPending) return;
+                  if (deleteState === "idle") {
+                    arm();
+                    return;
+                  }
+                  // armed: second click
+                  if (isSoloAdmin) {
+                    deleteGroup.mutate();
+                  } else {
+                    setDeleteState("choosing");
+                  }
+                }}
+                disabled={deleteGroup.isPending}
+                className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
+                  deleteState === "armed"
+                    ? "animate-shake bg-red-700 hover:bg-red-800"
+                    : "bg-red-600 hover:bg-red-700"
+                }`}
+                aria-live="polite"
+              >
+                {deleteGroup.isPending
+                  ? "Deleting..."
+                  : deleteState === "armed"
+                    ? isSoloAdmin
+                      ? "Click again to permanently delete"
+                      : "Click again to choose successor"
+                    : "Delete List"}
+              </button>
+            )}
+
+            {deleteState === "choosing" && (
+              <div className="space-y-2 rounded-md border border-red-300 p-3 dark:border-red-800">
+                <p className="text-sm font-medium text-red-700 dark:text-red-300">
+                  Choose a new admin. You will leave the list once ownership transfers.
+                </p>
+                {membersQuery.isLoading ? (
+                  <p className="text-sm text-gray-500">Loading members...</p>
+                ) : successors.length === 0 ? (
+                  <p className="text-sm text-gray-500">No other members available.</p>
+                ) : (
+                  <ul className="space-y-1">
+                    {successors.map((m) => (
+                      <li key={m.user_id}>
+                        <button
+                          onClick={() => transferAndLeave.mutate(m.user_id)}
+                          disabled={transferAndLeave.isPending}
+                          className="flex w-full items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
+                        >
+                          <span
+                            className="inline-block h-5 w-5 rounded-full"
+                            style={{ backgroundColor: m.users?.avatar_color ?? "#6b7280" }}
+                            aria-hidden="true"
+                          />
+                          <span>{m.users?.display_name ?? "Unknown"}</span>
+                        </button>
+                      </li>
+                    ))}
+                  </ul>
+                )}
+                <button
+                  onClick={() => setDeleteState("idle")}
+                  disabled={transferAndLeave.isPending}
+                  className="text-xs text-gray-600 hover:underline dark:text-gray-400"
+                >
+                  Cancel
+                </button>
+                {transferAndLeave.error && (
+                  <p className="text-sm text-red-500">{transferAndLeave.error.message}</p>
+                )}
+              </div>
+            )}
             {deleteGroup.error && (
               <p className="text-sm text-red-500">{deleteGroup.error.message}</p>
             )}
@@ -212,14 +342,26 @@ export function SettingsPanel({ groupId }: { groupId: string }) {
           <div className="space-y-2">
             <button
               onClick={() => {
-                if (confirm("Are you sure you want to leave this group?")) {
-                  leaveGroup.mutate();
+                if (leaveGroup.isPending) return;
+                if (deleteState === "idle") {
+                  arm();
+                  return;
                 }
+                leaveGroup.mutate();
               }}
               disabled={leaveGroup.isPending}
-              className="w-full rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
+              className={`w-full rounded-md border px-4 py-2 text-sm font-medium disabled:opacity-50 ${
+                deleteState === "armed"
+                  ? "animate-shake border-red-500 bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
+                  : "border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
+              }`}
+              aria-live="polite"
             >
-              {leaveGroup.isPending ? "Leaving..." : "Leave Group"}
+              {leaveGroup.isPending
+                ? "Leaving..."
+                : deleteState === "armed"
+                  ? "Click again to confirm"
+                  : "Leave List"}
             </button>
             {leaveGroup.error && <p className="text-sm text-red-500">{leaveGroup.error.message}</p>}
           </div>

+ 1 - 1
src/components/home/empty-state.tsx

@@ -12,7 +12,7 @@ export function EmptyState() {
       </p>
       <div className="mt-6 flex flex-col sm:flex-row gap-3">
         <Link
-          href="/create"
+          href="/create-group"
           className="inline-flex items-center justify-center rounded-lg bg-foreground text-background px-6 py-3 text-sm font-medium hover:opacity-90 transition-opacity"
         >
           Create List

+ 121 - 0
src/components/home/join-list-button.tsx

@@ -0,0 +1,121 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+/**
+ * <JoinListButton /> — collapsible form for joining a list by invite code.
+ *
+ * Opens an inline input on click, POSTs to /api/groups/join, then redirects
+ * to /list/{id} on success (or 409 "already a member" — same destination).
+ * Errors render inline; rate-limit (429) and "invalid code" (404) both
+ * surface user-friendly messages.
+ */
+export function JoinListButton() {
+  const router = useRouter();
+  const [open, setOpen] = useState(false);
+  const [code, setCode] = useState("");
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!code.trim() || submitting) return;
+    setSubmitting(true);
+    setError(null);
+    try {
+      const res = await fetch("/api/groups/join", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ invite_code: code.trim() }),
+      });
+      const data = await res.json().catch(() => ({}));
+
+      // 409 = already a member; still a successful "find the list" outcome,
+      // and the API already returns no `group` payload there, so we need to
+      // refetch — easiest route: tell the user, keep them on /home.
+      if (res.status === 409) {
+        setError("You're already in this list — check Your Lists below.");
+        setSubmitting(false);
+        return;
+      }
+
+      if (!res.ok || !data?.group?.id) {
+        setError(data?.error ?? "Couldn't find that list.");
+        setSubmitting(false);
+        return;
+      }
+
+      router.push(`/list/${data.group.id}`);
+    } catch {
+      setError("Network error. Try again.");
+      setSubmitting(false);
+    }
+  }
+
+  if (!open) {
+    return (
+      <button
+        type="button"
+        onClick={() => setOpen(true)}
+        className="mx-auto block w-full max-w-md rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground hover:bg-foreground/20 transition-colors text-center"
+        style={{ minHeight: 44 }}
+      >
+        ↪ Join a List
+      </button>
+    );
+  }
+
+  return (
+    <form
+      onSubmit={handleSubmit}
+      className="mx-auto flex w-full max-w-md flex-col gap-2 rounded-lg border border-foreground/10 bg-foreground/5 p-3"
+      aria-label="Join a list by invite code"
+    >
+      <label htmlFor="join-code-input" className="text-xs font-medium text-foreground/70">
+        Enter invite code
+      </label>
+      <input
+        id="join-code-input"
+        type="text"
+        autoFocus
+        autoComplete="off"
+        spellCheck={false}
+        value={code}
+        onChange={(e) => {
+          setCode(e.target.value);
+          if (error) setError(null);
+        }}
+        placeholder="WORD-WORD"
+        className="rounded-md border border-foreground/15 bg-background px-3 py-2 text-sm text-foreground placeholder-foreground/40 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
+      />
+      {error && (
+        <p role="alert" className="text-xs text-red-500">
+          {error}
+        </p>
+      )}
+      <div className="flex gap-2">
+        <button
+          type="submit"
+          disabled={!code.trim() || submitting}
+          className="flex-1 rounded-md bg-blue-700 px-3 py-2 text-sm font-medium text-white hover:bg-blue-600 transition-colors disabled:opacity-50"
+          style={{ minHeight: 44 }}
+        >
+          {submitting ? "Joining…" : "Join"}
+        </button>
+        <button
+          type="button"
+          onClick={() => {
+            setOpen(false);
+            setCode("");
+            setError(null);
+          }}
+          className="rounded-md border border-foreground/20 px-3 py-2 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors"
+          style={{ minHeight: 44 }}
+        >
+          Cancel
+        </button>
+      </div>
+    </form>
+  );
+}

+ 2 - 2
src/components/home/list-grid.tsx

@@ -9,7 +9,7 @@ export function ListGrid() {
 
   if (isLoading) {
     return (
-      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+      <div className="mx-auto flex max-w-md flex-col gap-4">
         {Array.from({ length: 3 }).map((_, i) => (
           <div key={i} className="h-24 animate-pulse rounded-lg bg-foreground/5" />
         ))}
@@ -30,7 +30,7 @@ export function ListGrid() {
   }
 
   return (
-    <div className="grid grid-cols-1 sm:grid-cols-2 gap-4" aria-live="polite">
+    <div className="mx-auto flex max-w-md flex-col gap-4" aria-live="polite">
       {groups.map((group) => (
         <ListCard key={group.id} group={group} />
       ))}

+ 49 - 35
src/components/home/roll-section.tsx

@@ -1,13 +1,15 @@
 "use client";
 
-import { useMemo, useState } from "react";
+import Link from "next/link";
+import { useCallback, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
 import { useAllUserMovies } from "@/hooks/use-all-user-movies";
-import { useUserGroups } from "@/hooks/use-user-groups";
+import { useCurrentUser } from "@/hooks/use-current-user";
 import { useRoll } from "@/hooks/use-roll";
-import { RollAnimation } from "@/components/dice/roll-animation";
 import { RollAnnouncer } from "@/components/dice/roll-announcer";
 import { GenreRollModal, type GenreRollPayload } from "@/components/dice/genre-roll-modal";
-import { HomeRollTeaserCard } from "@/components/home/home-roll-teaser-card";
+import { ListRollCarousel } from "@/components/dice/list-roll-carousel";
+import { JoinListButton } from "@/components/home/join-list-button";
 import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import type { Movie } from "@/types/movie";
 
@@ -25,24 +27,16 @@ import type { Movie } from "@/types/movie";
 
 export function RollSection() {
   const { data: pool, isLoading } = useAllUserMovies();
-  const { data: groups } = useUserGroups();
   const { result: winner, rollState, roll } = useRoll();
+  const queryClient = useQueryClient();
+  const { data: currentUser } = useCurrentUser();
 
   const [genreModalOpen, setGenreModalOpen] = useState(false);
   const [noMatchesBanner, setNoMatchesBanner] = useState(false);
-  // Captured at click time so real-time cache mutations can't change the
-  // in-flight scatter animation mid-roll.
-  const [activePool, setActivePool] = useState<Movie[]>([]);
 
   const fullPool: Movie[] = pool ?? [];
   const hasPool = !isLoading && fullPool.length > 0;
 
-  const groupNameById = useMemo(() => {
-    const map = new Map<string, string>();
-    for (const g of groups ?? []) map.set(g.id, g.name);
-    return map;
-  }, [groups]);
-
   const buttonsDisabled = isLoading || fullPool.length === 0;
   const tooltip = isLoading
     ? "Loading lists…"
@@ -53,7 +47,6 @@ export function RollSection() {
   function handleRandomRoll() {
     if (!hasPool) return;
     setNoMatchesBanner(false);
-    setActivePool(fullPool);
     roll(fullPool);
   }
 
@@ -68,26 +61,42 @@ export function RollSection() {
 
     const rollPool = noMatches ? fullPool : filtered;
     setNoMatchesBanner(noMatches);
-    setActivePool(rollPool);
     roll(rollPool);
   }
 
-  function handleReroll() {
-    roll();
-  }
-
-  const winnerGroupName = winner ? (groupNameById.get(winner.group_id) ?? null) : null;
+  // Cross-list watched toggle — `useToggleWatched` is parameterized per-group,
+  // but the home roll spans groups, so we run the mutation inline and
+  // invalidate the right caches manually.
+  const handleWatchedToggle = useCallback(
+    async (movie: Movie) => {
+      try {
+        const res = await fetch(`/api/movies/${movie.id}/watched`, {
+          method: "PATCH",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ watched: !movie.watched }),
+        });
+        if (!res.ok) return;
+        void queryClient.invalidateQueries({ queryKey: ["group-movies", movie.group_id] });
+        if (currentUser) {
+          void queryClient.invalidateQueries({ queryKey: ["all-user-movies", currentUser.id] });
+        }
+      } catch {
+        // ignore — UI just won't update
+      }
+    },
+    [queryClient, currentUser],
+  );
 
   return (
-    <div>
-      <div className="flex flex-wrap gap-3">
+    <div className="flex flex-col gap-4">
+      <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-center">
         <button
           type="button"
           onClick={handleRandomRoll}
           disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           title={tooltip}
-          className={`rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity ${
+          className={`w-full rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity sm:w-auto ${
             buttonsDisabled
               ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
               : "bg-foreground text-background hover:opacity-90"
@@ -102,7 +111,7 @@ export function RollSection() {
           disabled={buttonsDisabled}
           aria-disabled={buttonsDisabled}
           title={tooltip}
-          className={`rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity ${
+          className={`w-full rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity sm:w-auto ${
             buttonsDisabled
               ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
               : "bg-foreground/10 text-foreground hover:bg-foreground/20"
@@ -111,13 +120,22 @@ export function RollSection() {
         >
           🎭 Genre Roll!
         </button>
+        <Link
+          href="/create-group"
+          className="w-full rounded-lg bg-blue-700 text-white px-5 py-2.5 text-sm font-medium hover:bg-blue-600 transition-colors text-center sm:w-auto"
+          style={{ minHeight: 44 }}
+        >
+          + Create List
+        </Link>
       </div>
 
+      <JoinListButton />
+
       <RollAnnouncer state={rollState} winner={winner} />
 
       {noMatchesBanner && rollState !== "idle" && (
         <p
-          className="mt-3 rounded-md bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 text-xs text-foreground/80"
+          className="rounded-md bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 text-xs text-foreground/80"
           role="status"
         >
           No matches — showing full list
@@ -125,15 +143,11 @@ export function RollSection() {
       )}
 
       {rollState !== "idle" && hasPool && (
-        <RollAnimation pool={activePool} winner={winner} state={rollState} />
-      )}
-
-      {rollState === "complete" && winner && (
-        <HomeRollTeaserCard
-          movie={winner}
-          groupId={winner.group_id}
-          groupName={winnerGroupName}
-          onReroll={handleReroll}
+        <ListRollCarousel
+          pool={fullPool}
+          winner={winner}
+          state={rollState}
+          onWatchedToggle={handleWatchedToggle}
         />
       )}
 

+ 6 - 11
src/components/landing/carousel-animation.tsx

@@ -109,9 +109,7 @@ export function CarouselSection() {
         if (elapsed >= SPIN_DURATION) {
           // Snap so the midpoint of a gap between posters lands at viewport center.
           // That way the spread effect creates an evenly-bracketed slot for the card.
-          const halfViewport = viewportRef.current
-            ? viewportRef.current.offsetWidth / 2
-            : 0;
+          const halfViewport = viewportRef.current ? viewportRef.current.offsetWidth / 2 : 0;
           const gapMidOffset = ITEM_WIDTH + ITEM_GAP / 2;
           const i = Math.round(
             (scrollOffsetRef.current + halfViewport - gapMidOffset) / POSTER_STRIDE,
@@ -216,6 +214,7 @@ export function CarouselSection() {
           className="absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden"
         >
           <div ref={stripRef} className="flex gap-3" aria-label="Movie poster carousel">
+            {/* eslint-disable-next-line react-hooks/refs -- scrollOffsetRef is stable in settled phase; rAF loop writes transform imperatively */}
             {tripled.map((poster, i) => {
               const url = getTMDBImageUrl(poster.poster_path, "reel");
               const spread =
@@ -258,11 +257,11 @@ export function CarouselSection() {
         </div>
       </div>
 
-      <div className="flex gap-3">
+      <div className="flex w-full max-w-xs flex-col gap-3 sm:max-w-none sm:flex-row sm:justify-center">
         <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"
+          className="w-full rounded-xl bg-foreground px-8 py-3 text-lg font-semibold text-background transition-opacity hover:opacity-90 disabled:opacity-50 sm:w-auto"
         >
           {phase === "spinning" ? "Rolling..." : "Roll the Dice"}
         </button>
@@ -270,18 +269,14 @@ export function CarouselSection() {
           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"
+          className="w-full rounded-xl border border-foreground/20 px-8 py-3 text-lg font-semibold transition-colors hover:bg-foreground/5 disabled:opacity-50 sm:w-auto"
         >
           Genre Roll
         </button>
       </div>
 
       {modalOpen && (
-        <GenreRollModal
-          onClose={handleModalClose}
-          onRoll={handleGenreRoll}
-          loading={loading}
-        />
+        <GenreRollModal onClose={handleModalClose} onRoll={handleGenreRoll} loading={loading} />
       )}
     </div>
   );

+ 1 - 1
src/components/landing/teaser-card.tsx

@@ -20,7 +20,7 @@ export function TeaserCard({ movie }: TeaserCardProps) {
 
   return (
     <>
-      <div className="flex w-40 flex-col items-center rounded-xl bg-background/95 p-3 shadow-2xl ring-1 ring-foreground/10 backdrop-blur">
+      <div className="flex w-40 flex-col items-center rounded-xl bg-background/95 p-3 ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)] backdrop-blur">
         {posterUrl ? (
           /* eslint-disable-next-line @next/next/no-img-element */
           <img

+ 30 - 52
src/components/movies/movie-list-client.tsx

@@ -9,10 +9,10 @@ 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 { useToggleWatched } from "@/hooks/use-toggle-watched";
 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 { ListRollCarousel } from "@/components/dice/list-roll-carousel";
 import { GenreRollModal } from "@/components/dice/genre-roll-modal";
 import { filterByGenresAndEmotionsStructured } from "@/lib/dice/genre-filter";
 import type { Database } from "@/types/database";
@@ -33,25 +33,22 @@ interface MovieListClientProps {
 
 export function MovieListClient({ groupId, members }: MovieListClientProps) {
   const [searchQuery, setSearchQuery] = useState("");
+  const [resultsHidden, setResultsHidden] = useState(false);
   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, isLoading } = useGroupMovies(groupId);
   const allMovies = useMemo(() => data?.pages.flat() ?? [], [data]);
 
-  const unwatchedPool = useMemo(
-    () => allMovies.filter((m) => !m.watched),
-    [allMovies],
-  );
+  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 toggleWatched = useToggleWatched(groupId);
   const { result: winner, rollState, roll } = useRoll();
 
   useRealtimeMovies(groupId);
@@ -67,21 +64,15 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
     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) => {
     setSearchQuery(query);
+    setResultsHidden(false);
   }, []);
 
   const handleAdd = useCallback(
     (tmdbId: number) => {
       setAddingTmdbId(tmdbId);
+      setResultsHidden(true);
       addMovie.mutate(
         { tmdb_id: tmdbId, group_id: groupId },
         {
@@ -95,7 +86,6 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
   const handleRandomRoll = useCallback(() => {
     if (isLoading || poolEmpty) return;
     setNoMatchesBanner(false);
-    setRollPool(unwatchedPool);
     roll(unwatchedPool);
   }, [isLoading, poolEmpty, unwatchedPool, roll]);
 
@@ -113,35 +103,40 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
       );
       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(
+  const handleWatchedToggle = useCallback(
     (movie: MovieRow) => {
-      setSelectedMovieId(movie.id);
+      toggleWatched.mutate({ movieId: movie.id, watched: !movie.watched });
     },
-    [],
+    [toggleWatched],
   );
 
   return (
     <div className="space-y-6">
+      <RollBar
+        isLoading={isLoading}
+        poolEmpty={poolEmpty}
+        onRoll={handleRandomRoll}
+        onGenreRoll={handleGenreRollOpen}
+      />
+
       <div>
         <SearchBar onSearch={handleSearch} isLoading={isSearchLoading} />
-        {searchQuery.length >= 2 && (
+        {addMovie.isError && (
+          <p role="alert" aria-live="polite" className="mt-2 text-sm text-red-500">
+            Couldn&apos;t add movie. Please try again.
+            {addMovie.error?.message ? ` (${addMovie.error.message})` : ""}
+          </p>
+        )}
+        {searchQuery.length >= 2 && !resultsHidden && (
           <div className="mt-2 rounded-lg border border-white/10 bg-white/5 p-3">
             <SearchResults
               tmdbResults={tmdbResults}
@@ -155,13 +150,6 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
         )}
       </div>
 
-      <RollBar
-        isLoading={isLoading}
-        poolEmpty={poolEmpty}
-        onRoll={handleRandomRoll}
-        onGenreRoll={handleGenreRollOpen}
-      />
-
       <RollAnnouncer state={rollState} winner={winner} />
 
       {noMatchesBanner && (
@@ -176,26 +164,16 @@ export function MovieListClient({ groupId, members }: MovieListClientProps) {
       )}
 
       {rollState !== "idle" && (
-        <RollAnimation pool={rollPool} winner={winner} state={rollState} />
-      )}
-
-      {rollState === "complete" && winner && (
-        <RollResultCard
-          movie={winner}
-          onReroll={handleReroll}
-          onOpen={handleOpenWinner}
+        <ListRollCarousel
+          pool={allMovies}
+          winner={winner}
+          state={rollState}
+          onWatchedToggle={handleWatchedToggle}
         />
       )}
 
       <section aria-label="Movie list" aria-live="polite">
-        <PosterGrid
-          groupId={groupId}
-          users={users}
-          onSelect={handleSelect}
-          selectedMovieId={selectedMovieId}
-          onClosePanel={handleClosePanel}
-          userNames={userNames}
-        />
+        <PosterGrid groupId={groupId} users={users} userNames={userNames} />
       </section>
 
       {genreModalOpen && (

+ 27 - 6
src/components/movies/poster-card.tsx

@@ -8,21 +8,23 @@ type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
 interface PosterCardProps {
   movie: MovieRow;
   avatarColor?: string | null;
-  onSelect: (movie: MovieRow) => void;
+  onInfo: (movie: MovieRow) => void;
 }
 
-export function PosterCard({ movie, avatarColor, onSelect }: PosterCardProps) {
+export function PosterCard({ movie, avatarColor, onInfo }: 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"
+      onClick={() => onInfo(movie)}
+      aria-label={`More info about ${movie.title}`}
+      className="w-full text-left rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
     >
       <div className="relative aspect-[2/3] overflow-hidden rounded-lg bg-gray-800">
         {posterUrl ? (
+          /* eslint-disable-next-line @next/next/no-img-element */
           <img
             src={posterUrl}
             alt={altText}
@@ -37,10 +39,22 @@ export function PosterCard({ movie, avatarColor, onSelect }: PosterCardProps) {
 
         {movie.watched && (
           <span
-            className="absolute top-1.5 left-1.5 text-lg leading-none"
+            className="absolute top-1.5 left-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-green-600 text-white shadow-lg ring-2 ring-white/80"
             aria-label="Watched"
           >
-            🔭
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth={3}
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              className="h-4 w-4"
+              aria-hidden="true"
+            >
+              <polyline points="20 6 9 17 4 12" />
+            </svg>
           </span>
         )}
 
@@ -51,6 +65,13 @@ export function PosterCard({ movie, avatarColor, onSelect }: PosterCardProps) {
             aria-hidden="true"
           />
         )}
+
+        <span
+          aria-hidden="true"
+          className="pointer-events-none absolute bottom-1.5 right-1.5 flex h-9 w-9 items-center justify-center rounded-full bg-black/65 text-base font-serif italic text-white shadow-lg ring-1 ring-white/30"
+        >
+          i
+        </span>
       </div>
       <p className="mt-1 truncate text-sm font-medium text-gray-100">{movie.title}</p>
     </button>

+ 12 - 8
src/hooks/use-add-movie.ts

@@ -1,6 +1,6 @@
 import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 import type { Database } from "@/types/database";
+import { useCurrentUser } from "@/hooks/use-current-user";
 
 type Movie = Database["public"]["Tables"]["movies"]["Row"];
 
@@ -11,6 +11,7 @@ interface AddMovieInput {
 
 export function useAddMovie() {
   const queryClient = useQueryClient();
+  const { data: currentUser } = useCurrentUser();
 
   return useMutation<Movie, Error, AddMovieInput>({
     mutationFn: async (input) => {
@@ -28,15 +29,18 @@ export function useAddMovie() {
       const data = await res.json();
       return data.movie;
     },
-    onSuccess: async (_data, variables) => {
+    onSuccess: (_data, variables) => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", variables.group_id] });
-      const supabase = getSupabaseBrowserClient();
-      const {
-        data: { user },
-      } = await supabase.auth.getUser();
-      if (user) {
-        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      if (currentUser) {
+        void queryClient.invalidateQueries({
+          queryKey: ["all-user-movies", currentUser.id],
+        });
       }
     },
+    onError: (error) => {
+      // Don't fail silently — log so failures surface in dev/Sentry. The
+      // user-visible message is rendered by consumers off `mutation.error`.
+      console.error("[useAddMovie] add failed:", error);
+    },
   });
 }

+ 5 - 31
src/hooks/use-all-user-movies.ts

@@ -1,8 +1,8 @@
 "use client";
 
-import { useEffect, useState } from "react";
 import { useQuery } from "@tanstack/react-query";
 import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { useCurrentUser } from "@/hooks/use-current-user";
 import type { Database } from "@/types/database";
 
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
@@ -43,37 +43,11 @@ async function fetchAllUserMovies(): Promise<MovieRow[]> {
   return deduped;
 }
 
-/**
- * Tracks the current Supabase auth user id so the query key changes when
- * the session changes (sign-in, sign-out, recovery claim, etc.). The id is
- * NOT used as a filter — RLS still does that — it only keys the cache.
- */
-function useCurrentUserId(): string | null | undefined {
-  const [userId, setUserId] = useState<string | null | undefined>(undefined);
-
-  useEffect(() => {
-    const supabase = getSupabaseBrowserClient();
-    let cancelled = false;
-
-    supabase.auth.getUser().then(({ data }) => {
-      if (!cancelled) setUserId(data.user?.id ?? null);
-    });
-
-    const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
-      setUserId(session?.user?.id ?? null);
-    });
-
-    return () => {
-      cancelled = true;
-      sub.subscription.unsubscribe();
-    };
-  }, []);
-
-  return userId;
-}
-
 export function useAllUserMovies() {
-  const userId = useCurrentUserId();
+  // Key the cache on the current user id so it changes across signin /
+  // recovery-claim / signout. Authority on rows is RLS, not this id.
+  const { data: currentUser, isPending } = useCurrentUser();
+  const userId = isPending ? undefined : (currentUser?.id ?? null);
 
   return useQuery({
     queryKey: ["all-user-movies", userId ?? null],

+ 7 - 8
src/hooks/use-delete-movie.ts

@@ -1,7 +1,7 @@
 "use client";
 
 import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { useCurrentUser } from "@/hooks/use-current-user";
 
 async function deleteMovie(movieId: string) {
   const res = await fetch(`/api/movies/${movieId}`, {
@@ -18,17 +18,16 @@ async function deleteMovie(movieId: string) {
 
 export function useDeleteMovie(groupId: string) {
   const queryClient = useQueryClient();
+  const { data: currentUser } = useCurrentUser();
 
   return useMutation({
     mutationFn: deleteMovie,
-    onSuccess: async () => {
+    onSuccess: () => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", groupId] });
-      const supabase = getSupabaseBrowserClient();
-      const {
-        data: { user },
-      } = await supabase.auth.getUser();
-      if (user) {
-        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      if (currentUser) {
+        void queryClient.invalidateQueries({
+          queryKey: ["all-user-movies", currentUser.id],
+        });
       }
     },
   });

+ 9 - 27
src/hooks/use-realtime-movies.ts

@@ -1,12 +1,12 @@
 "use client";
 
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback } from "react";
 import type { RealtimePostgresChangesPayload } from "@supabase/supabase-js";
 import { useQueryClient, type InfiniteData } from "@tanstack/react-query";
 import type { Database } from "@/types/database";
 import { useRealtimeChannel } from "./use-realtime-channel";
 import { buildEqFilter } from "@/lib/realtime/subscription-manager";
-import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { useCurrentUser } from "@/hooks/use-current-user";
 
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
 
@@ -21,21 +21,11 @@ function moviesQueryKey(groupId: string): readonly [string, string] {
  */
 export function useRealtimeMovies(groupId: string | null) {
   const queryClient = useQueryClient();
-  const userIdRef = useRef<string | null>(null);
-
-  // Resolve current user id once so realtime events can dual-invalidate
+  // Use the current user id so realtime events can dual-invalidate
   // the cross-list ["all-user-movies", userId] query key alongside the
   // per-group ["group-movies", groupId] cache update.
-  useEffect(() => {
-    let cancelled = false;
-    const supabase = getSupabaseBrowserClient();
-    void supabase.auth.getUser().then(({ data }) => {
-      if (!cancelled) userIdRef.current = data.user?.id ?? null;
-    });
-    return () => {
-      cancelled = true;
-    };
-  }, []);
+  const { data: currentUser } = useCurrentUser();
+  const userId = currentUser?.id ?? null;
 
   const handlePayload = useCallback(
     (payload: RealtimePostgresChangesPayload<MovieRow>) => {
@@ -48,15 +38,13 @@ export function useRealtimeMovies(groupId: string | null) {
         queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
           if (!old) return undefined;
           // Avoid duplicates across all pages
-          if (old.pages.some((page) => page.some((m) => m.id === newMovie.id)))
-            return old;
+          if (old.pages.some((page) => page.some((m) => m.id === newMovie.id))) return old;
           // Prepend to first page (ordered by added_at DESC)
           return {
             pages: [[newMovie, ...old.pages[0]], ...old.pages.slice(1)],
             pageParams: old.pageParams,
           };
         });
-        const userId = userIdRef.current;
         if (userId) {
           void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
         }
@@ -65,13 +53,10 @@ export function useRealtimeMovies(groupId: string | null) {
         queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
           if (!old) return undefined;
           return {
-            pages: old.pages.map((page) =>
-              page.map((m) => (m.id === updated.id ? updated : m)),
-            ),
+            pages: old.pages.map((page) => page.map((m) => (m.id === updated.id ? updated : m))),
             pageParams: old.pageParams,
           };
         });
-        const userId = userIdRef.current;
         if (userId) {
           void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
         }
@@ -81,20 +66,17 @@ export function useRealtimeMovies(groupId: string | null) {
           queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
             if (!old) return undefined;
             return {
-              pages: old.pages.map((page) =>
-                page.filter((m) => m.id !== deleted.id),
-              ),
+              pages: old.pages.map((page) => page.filter((m) => m.id !== deleted.id)),
               pageParams: old.pageParams,
             };
           });
-          const userId = userIdRef.current;
           if (userId) {
             void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
           }
         }
       }
     },
-    [groupId, queryClient],
+    [groupId, queryClient, userId],
   );
 
   const { status } = useRealtimeChannel<MovieRow>({

+ 7 - 8
src/hooks/use-toggle-watched.ts

@@ -1,7 +1,7 @@
 "use client";
 
 import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { useCurrentUser } from "@/hooks/use-current-user";
 
 interface ToggleWatchedVars {
   movieId: string;
@@ -25,17 +25,16 @@ async function toggleWatched({ movieId, watched }: ToggleWatchedVars) {
 
 export function useToggleWatched(groupId: string) {
   const queryClient = useQueryClient();
+  const { data: currentUser } = useCurrentUser();
 
   return useMutation({
     mutationFn: toggleWatched,
-    onSuccess: async () => {
+    onSuccess: () => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", groupId] });
-      const supabase = getSupabaseBrowserClient();
-      const {
-        data: { user },
-      } = await supabase.auth.getUser();
-      if (user) {
-        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      if (currentUser) {
+        void queryClient.invalidateQueries({
+          queryKey: ["all-user-movies", currentUser.id],
+        });
       }
     },
   });

+ 9 - 9
src/hooks/use-user-groups.ts

@@ -2,6 +2,7 @@
 
 import { useQuery } from "@tanstack/react-query";
 import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { useCurrentUser } from "@/hooks/use-current-user";
 
 export interface UserGroup {
   id: string;
@@ -11,18 +12,13 @@ export interface UserGroup {
   movie_count: number;
 }
 
-async function fetchUserGroups(): Promise<UserGroup[]> {
+async function fetchUserGroups(userId: string): Promise<UserGroup[]> {
   const supabase = getSupabaseBrowserClient();
 
-  const {
-    data: { user },
-  } = await supabase.auth.getUser();
-  if (!user) return [];
-
   const { data: memberships, error: memberError } = await supabase
     .from("group_members")
     .select("group_id")
-    .eq("user_id", user.id);
+    .eq("user_id", userId);
 
   if (memberError) throw memberError;
   if (!memberships || memberships.length === 0) return [];
@@ -64,9 +60,13 @@ async function fetchUserGroups(): Promise<UserGroup[]> {
 }
 
 export function useUserGroups() {
+  const { data: currentUser } = useCurrentUser();
+  const userId = currentUser?.id ?? null;
+
   return useQuery({
-    queryKey: ["user-groups"],
-    queryFn: fetchUserGroups,
+    queryKey: ["user-groups", userId],
+    queryFn: () => fetchUserGroups(userId as string),
+    enabled: !!userId,
     staleTime: 30 * 1000,
     refetchInterval: 30 * 1000,
   });