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