Просмотр исходного кода

Merge branch 'worktree-agent-afdf7ba2'

User 2 месяцев назад
Родитель
Сommit
1c8df4e25c
3 измененных файлов с 274 добавлено и 23 удалено
  1. 11 23
      src/app/(app)/home/page.tsx
  2. 112 0
      src/components/home/home-roll-teaser-card.tsx
  3. 151 0
      src/components/home/roll-section.tsx

+ 11 - 23
src/app/(app)/home/page.tsx

@@ -2,6 +2,7 @@
 
 
 import Link from "next/link";
 import Link from "next/link";
 import { ListGrid } from "@/components/home/list-grid";
 import { ListGrid } from "@/components/home/list-grid";
+import { RollSection } from "@/components/home/roll-section";
 
 
 export default function HomePage() {
 export default function HomePage() {
   return (
   return (
@@ -9,29 +10,16 @@ export default function HomePage() {
       <section className="mb-8">
       <section className="mb-8">
         <h1 className="text-2xl font-bold text-foreground">Your Lists</h1>
         <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>
         <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-wrap gap-3">
-          <button
-            type="button"
-            disabled
-            className="rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground/40 cursor-not-allowed"
-            title="Coming soon"
-          >
-            🎲 Roll the Dice
-          </button>
-          <button
-            type="button"
-            disabled
-            className="rounded-lg bg-foreground/10 px-5 py-2.5 text-sm font-medium text-foreground/40 cursor-not-allowed"
-            title="Coming soon"
-          >
-            🎭 Genre Roll
-          </button>
-          <Link
-            href="/create"
-            className="rounded-lg bg-foreground text-background px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
-          >
-            + Create List
-          </Link>
+        <div className="mt-4 flex flex-col gap-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>
         </div>
       </section>
       </section>
 
 

+ 112 - 0
src/components/home/home-roll-teaser-card.tsx

@@ -0,0 +1,112 @@
+"use client";
+
+import Link from "next/link";
+import type { Movie } from "@/types/movie";
+import { TMDB_GENRE_MAP, getTMDBImageUrl } from "@/types/tmdb";
+
+/**
+ * <HomeRollTeaserCard /> — in-place result card for the home-page cross-list
+ * roll. Renders IN PLACE per PROJECT_SCOPE.md:222-223; the only navigation
+ * is the user-initiated "Open list" link.
+ *
+ * Uses the DB `Movie` row shape (not the TMDB landing teaser shape). Titles,
+ * genres, and group names are rendered as React text children only — no
+ * `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
+ */
+
+interface HomeRollTeaserCardProps {
+  movie: Movie;
+  groupId: string;
+  groupName: string | null;
+  onReroll: () => void;
+}
+
+function genreLabelsFromIds(ids: string[]): string[] {
+  const labels: string[] = [];
+  for (const raw of ids) {
+    const id = Number.parseInt(raw, 10);
+    if (Number.isNaN(id)) continue;
+    const name = TMDB_GENRE_MAP[id];
+    if (name) labels.push(name);
+  }
+  return labels;
+}
+
+export function HomeRollTeaserCard({
+  movie,
+  groupId,
+  groupName,
+  onReroll,
+}: HomeRollTeaserCardProps) {
+  const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
+  const genreLabels = genreLabelsFromIds(movie.genres ?? []);
+
+  return (
+    <div
+      className="mt-4 rounded-xl border border-foreground/10 bg-foreground/5 p-4 sm:p-6"
+      data-testid="home-roll-teaser-card"
+    >
+      <div className="flex flex-col gap-4 sm:flex-row">
+        {posterUrl ? (
+          // eslint-disable-next-line @next/next/no-img-element
+          <img
+            src={posterUrl}
+            alt={movie.title}
+            loading="lazy"
+            className="w-32 h-48 rounded-lg object-cover self-center sm:self-start"
+          />
+        ) : (
+          <div className="w-32 h-48 rounded-lg bg-foreground/10 flex items-center justify-center text-xs text-foreground/50 self-center sm:self-start">
+            No poster
+          </div>
+        )}
+
+        <div className="flex-1 min-w-0">
+          <h2 className="text-lg sm:text-xl font-semibold text-foreground break-words">
+            {movie.title}
+          </h2>
+          <p className="mt-1 text-sm text-foreground/60">{movie.year}</p>
+
+          {genreLabels.length > 0 && (
+            <div className="mt-2 flex flex-wrap gap-1.5">
+              {genreLabels.map((label) => (
+                <span
+                  key={label}
+                  className="rounded-full bg-foreground/10 px-2 py-0.5 text-xs text-foreground/70"
+                >
+                  {label}
+                </span>
+              ))}
+            </div>
+          )}
+
+          <p
+            className="mt-3 text-xs text-foreground/60 truncate"
+            style={{ maxWidth: "22rem" }}
+          >
+            from {groupName ?? "a list"}
+          </p>
+
+          <div className="mt-4 flex flex-wrap gap-2">
+            <button
+              type="button"
+              onClick={onReroll}
+              aria-label="Re-roll the dice"
+              className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity"
+              style={{ minHeight: 44 }}
+            >
+              🎲 Re-roll
+            </button>
+            <Link
+              href={`/list/${groupId}`}
+              className="rounded-lg border border-foreground/20 px-4 py-2 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors inline-flex items-center"
+              style={{ minHeight: 44 }}
+            >
+              Open list
+            </Link>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 151 - 0
src/components/home/roll-section.tsx

@@ -0,0 +1,151 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useAllUserMovies } from "@/hooks/use-all-user-movies";
+import { useUserGroups } from "@/hooks/use-user-groups";
+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 { filterByGenresAndEmotions } from "@/lib/dice/genre-filter";
+import type { Movie } from "@/types/movie";
+
+/**
+ * <RollSection /> — home-page cross-list randomizer entry point.
+ *
+ * The home-page roll renders IN PLACE — no `router.push()` on the roll path
+ * (PROJECT_SCOPE.md:222-223). User stays on `/home`.
+ *
+ * Genre roll filter: uses the legacy string-tokenizer `filterByGenresAndEmotions`
+ * (Option A from the PHASE4 plan). Mood keys resolve via EMOTION_TO_GENRE_MAP;
+ * bare numeric genre IDs pass through the tokenizer without matching, so a
+ * genre-only selection currently degrades to a full-pool roll. U8 owns the
+ * structured replacement in a parallel branch we must not touch.
+ */
+
+export function RollSection() {
+  const { data: pool, isLoading } = useAllUserMovies();
+  const { data: groups } = useUserGroups();
+  const { result: winner, rollState, roll } = useRoll();
+
+  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…"
+    : fullPool.length === 0
+      ? "Nothing to roll"
+      : undefined;
+
+  function handleRandomRoll() {
+    if (!hasPool) return;
+    setNoMatchesBanner(false);
+    setActivePool(fullPool);
+    roll(fullPool);
+  }
+
+  function handleGenreRoll(payload: GenreRollPayload) {
+    setGenreModalOpen(false);
+    if (!hasPool) return;
+
+    const tokens = [...payload.genreIds.map(String), ...payload.moodKeys].join(" ");
+    const { movies: filtered, noMatches } = filterByGenresAndEmotions(tokens, fullPool);
+
+    // On no-match, filterByGenresAndEmotions already returns the full pool,
+    // but we roll explicitly on `fullPool` so future contract changes can't
+    // silently break the fallback.
+    const rollPool = noMatches ? fullPool : filtered;
+    setNoMatchesBanner(noMatches);
+    setActivePool(rollPool);
+    roll(rollPool);
+  }
+
+  function handleReroll() {
+    roll();
+  }
+
+  const winnerGroupName = winner ? (groupNameById.get(winner.group_id) ?? null) : null;
+
+  return (
+    <div>
+      <div className="flex flex-wrap gap-3">
+        <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 ${
+            buttonsDisabled
+              ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
+              : "bg-foreground text-background hover:opacity-90"
+          }`}
+          style={{ minHeight: 44, minWidth: 44 }}
+        >
+          🎲 Roll the Dice!
+        </button>
+        <button
+          type="button"
+          onClick={() => setGenreModalOpen(true)}
+          disabled={buttonsDisabled}
+          aria-disabled={buttonsDisabled}
+          title={tooltip}
+          className={`rounded-lg px-5 py-2.5 text-sm font-medium transition-opacity ${
+            buttonsDisabled
+              ? "bg-foreground/10 text-foreground/40 cursor-not-allowed"
+              : "bg-foreground/10 text-foreground hover:bg-foreground/20"
+          }`}
+          style={{ minHeight: 44, minWidth: 44 }}
+        >
+          🎭 Genre Roll!
+        </button>
+      </div>
+
+      <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"
+          role="status"
+        >
+          No matches — showing full list
+        </p>
+      )}
+
+      {rollState !== "idle" && hasPool && (
+        <RollAnimation pool={activePool} winner={winner} state={rollState} />
+      )}
+
+      {rollState === "complete" && winner && (
+        <HomeRollTeaserCard
+          movie={winner}
+          groupId={winner.group_id}
+          groupName={winnerGroupName}
+          onReroll={handleReroll}
+        />
+      )}
+
+      {genreModalOpen && (
+        <GenreRollModal
+          onClose={() => setGenreModalOpen(false)}
+          onRoll={handleGenreRoll}
+          loading={false}
+        />
+      )}
+    </div>
+  );
+}