Explorar o código

feat: add genre roll modal with chip-based genre/mood selection

Standalone modal component for the landing page genre roll flow.
Supports toggling up to 5 genres/moods via chips, search filtering,
and resolves mood selections to TMDB genre IDs via EMOTION_TO_GENRE_MAP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User hai 2 meses
pai
achega
7eae7535fd
Modificáronse 1 ficheiros con 202 adicións e 0 borrados
  1. 202 0
      src/components/landing/genre-roll-modal.tsx

+ 202 - 0
src/components/landing/genre-roll-modal.tsx

@@ -0,0 +1,202 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { EMOTION_TO_GENRE_MAP } from "@/lib/constants";
+import { TMDB_GENRE_MAP } from "@/types/tmdb";
+
+const CANONICAL_MOODS: { key: string; label: string; synonyms: string[] }[] = [
+  { key: "happy", label: "Happy", synonyms: ["cheerful", "upbeat", "fun"] },
+  { key: "sad", label: "Sad", synonyms: ["emotional", "cry", "tearjerker"] },
+  { key: "excited", label: "Excited", synonyms: ["hyped", "energetic", "pumped"] },
+  { key: "scared", label: "Scared", synonyms: ["tense", "nervous", "creepy"] },
+  { key: "calm", label: "Calm", synonyms: ["relaxed", "chill", "cozy"] },
+  { key: "romantic", label: "Romantic", synonyms: ["lovey", "date night"] },
+  { key: "thoughtful", label: "Thoughtful", synonyms: ["reflective", "deep"] },
+  { key: "funny", label: "Funny", synonyms: ["silly", "goofy", "laugh"] },
+  { key: "dark", label: "Dark", synonyms: ["gritty", "intense", "serious"] },
+  { key: "nostalgic", label: "Nostalgic", synonyms: ["classic", "retro"] },
+];
+
+const MAX_SELECTIONS = 5;
+
+const GENRE_ENTRIES = Object.entries(TMDB_GENRE_MAP).map(([id, name]) => ({
+  id: Number(id),
+  name,
+}));
+
+function chipClassName(selected: boolean, disabled: boolean): string {
+  const base = "rounded-full px-3 py-1 text-sm";
+  if (selected) return `${base} bg-foreground text-background cursor-pointer`;
+  if (disabled) return `${base} bg-foreground/10 text-foreground/70 opacity-40 cursor-not-allowed`;
+  return `${base} bg-foreground/10 text-foreground/70 cursor-pointer`;
+}
+
+interface GenreRollModalProps {
+  onClose: () => void;
+  onRoll: (genreIds: number[]) => void;
+  loading: boolean;
+}
+
+export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps) {
+  const [searchQuery, setSearchQuery] = useState("");
+  const [selectedGenres, setSelectedGenres] = useState<Set<number>>(new Set());
+  const [selectedMoods, setSelectedMoods] = useState<Set<string>>(new Set());
+
+  const totalSelected = selectedGenres.size + selectedMoods.size;
+  const atMax = totalSelected >= MAX_SELECTIONS;
+
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === "Escape") onClose();
+    }
+    document.addEventListener("keydown", handleKeyDown);
+    return () => document.removeEventListener("keydown", handleKeyDown);
+  }, [onClose]);
+
+  const query = searchQuery.toLowerCase();
+
+  const filteredGenres = query
+    ? GENRE_ENTRIES.filter((g) => g.name.toLowerCase().includes(query))
+    : GENRE_ENTRIES;
+
+  const filteredMoods = query
+    ? CANONICAL_MOODS.filter(
+        (m) =>
+          m.label.toLowerCase().includes(query) ||
+          m.synonyms.some((s) => s.includes(query)),
+      )
+    : CANONICAL_MOODS;
+
+  function toggleGenre(id: number) {
+    setSelectedGenres((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) {
+        next.delete(id);
+      } else if (prev.size + selectedMoods.size < MAX_SELECTIONS) {
+        next.add(id);
+      }
+      return next;
+    });
+  }
+
+  function toggleMood(key: string) {
+    setSelectedMoods((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) {
+        next.delete(key);
+      } else if (prev.size + selectedGenres.size < MAX_SELECTIONS) {
+        next.add(key);
+      }
+      return next;
+    });
+  }
+
+  function handleRoll() {
+    const ids = new Set<number>(selectedGenres);
+
+    for (const moodKey of selectedMoods) {
+      const mapping = EMOTION_TO_GENRE_MAP[moodKey];
+      if (mapping) {
+        for (const id of mapping.primary) {
+          ids.add(id);
+        }
+      }
+    }
+
+    onRoll(Array.from(ids));
+  }
+
+  function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
+    if (e.target === e.currentTarget) onClose();
+  }
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="genre-roll-title"
+      onClick={handleBackdropClick}
+    >
+      <div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
+        <h2 id="genre-roll-title" className="text-lg font-semibold mb-4">
+          Pick Genres &amp; Moods
+        </h2>
+
+        <input
+          type="text"
+          placeholder="Filter genres and moods..."
+          value={searchQuery}
+          onChange={(e) => setSearchQuery(e.target.value)}
+          className="mb-4 w-full rounded-md border border-foreground/20 bg-transparent px-3 py-2 text-sm placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-foreground/30"
+        />
+
+        <p className="mb-3 text-sm text-foreground/60">
+          {totalSelected}/{MAX_SELECTIONS} selected
+        </p>
+
+        <div className="max-h-64 overflow-y-auto space-y-4">
+          {filteredGenres.length > 0 && (
+            <div>
+              <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-foreground/50">
+                Genres
+              </h3>
+              <div className="flex flex-wrap gap-2">
+                {filteredGenres.map((genre) => {
+                  const isSelected = selectedGenres.has(genre.id);
+                  const isDisabled = atMax && !isSelected;
+                  return (
+                    <button
+                      key={genre.id}
+                      type="button"
+                      disabled={isDisabled}
+                      onClick={() => toggleGenre(genre.id)}
+                      className={chipClassName(isSelected, isDisabled)}
+                    >
+                      {genre.name}
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+
+          {filteredMoods.length > 0 && (
+            <div>
+              <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-foreground/50">
+                Moods
+              </h3>
+              <div className="flex flex-wrap gap-2">
+                {filteredMoods.map((mood) => {
+                  const isSelected = selectedMoods.has(mood.key);
+                  const isDisabled = atMax && !isSelected;
+                  return (
+                    <button
+                      key={mood.key}
+                      type="button"
+                      disabled={isDisabled}
+                      onClick={() => toggleMood(mood.key)}
+                      className={chipClassName(isSelected, isDisabled)}
+                    >
+                      {mood.label}
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          )}
+        </div>
+
+        <button
+          type="button"
+          disabled={totalSelected === 0 || loading}
+          onClick={handleRoll}
+          className="mt-4 w-full 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"
+        >
+          {loading ? "Rolling..." : "Roll!"}
+        </button>
+      </div>
+    </div>
+  );
+}