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