Procházet zdrojové kódy

Merge branch 'worktree-agent-a035178e'

User před 2 měsíci
rodič
revize
955a26372c

+ 58 - 15
src/components/landing/genre-roll-modal.tsx → src/components/dice/genre-roll-modal.tsx

@@ -1,8 +1,7 @@
 "use client";
 
-import { useEffect, useState } from "react";
+import { useEffect, useRef, 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[] }[] = [
@@ -25,6 +24,9 @@ const GENRE_ENTRIES = Object.entries(TMDB_GENRE_MAP).map(([id, name]) => ({
   name,
 }));
 
+const FOCUSABLE_SELECTOR =
+  'button, input, [tabindex]:not([tabindex="-1"])';
+
 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`;
@@ -32,9 +34,14 @@ function chipClassName(selected: boolean, disabled: boolean): string {
   return `${base} bg-foreground/10 text-foreground/70 cursor-pointer`;
 }
 
+export interface GenreRollPayload {
+  genreIds: number[];
+  moodKeys: string[];
+}
+
 interface GenreRollModalProps {
   onClose: () => void;
-  onRoll: (genreIds: number[]) => void;
+  onRoll: (payload: GenreRollPayload) => void;
   loading: boolean;
 }
 
@@ -43,6 +50,9 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
   const [selectedGenres, setSelectedGenres] = useState<Set<number>>(new Set());
   const [selectedMoods, setSelectedMoods] = useState<Set<string>>(new Set());
 
+  const dialogRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
   const totalSelected = selectedGenres.size + selectedMoods.size;
   const atMax = totalSelected >= MAX_SELECTIONS;
 
@@ -54,6 +64,45 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
     return () => document.removeEventListener("keydown", handleKeyDown);
   }, [onClose]);
 
+  useEffect(() => {
+    const previouslyFocused = document.activeElement as HTMLElement | null;
+    searchInputRef.current?.focus();
+
+    function handleTabKey(e: KeyboardEvent) {
+      if (e.key !== "Tab") return;
+      const container = dialogRef.current;
+      if (!container) return;
+      const focusables = Array.from(
+        container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
+      ).filter((el) => !el.hasAttribute("disabled"));
+      if (focusables.length === 0) return;
+
+      const first = focusables[0];
+      const last = focusables[focusables.length - 1];
+      const active = document.activeElement as HTMLElement | null;
+
+      if (e.shiftKey) {
+        if (active === first || !container.contains(active)) {
+          e.preventDefault();
+          last.focus();
+        }
+      } else {
+        if (active === last || !container.contains(active)) {
+          e.preventDefault();
+          first.focus();
+        }
+      }
+    }
+
+    document.addEventListener("keydown", handleTabKey);
+    return () => {
+      document.removeEventListener("keydown", handleTabKey);
+      if (previouslyFocused && document.contains(previouslyFocused)) {
+        previouslyFocused.focus();
+      }
+    };
+  }, []);
+
   const query = searchQuery.toLowerCase();
 
   const filteredGenres = query
@@ -93,18 +142,10 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
   }
 
   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));
+    onRoll({
+      genreIds: Array.from(selectedGenres),
+      moodKeys: Array.from(selectedMoods),
+    });
   }
 
   function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
@@ -118,6 +159,7 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
       aria-modal="true"
       aria-labelledby="genre-roll-title"
       onClick={handleBackdropClick}
+      ref={dialogRef}
     >
       <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">
@@ -125,6 +167,7 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
         </h2>
 
         <input
+          ref={searchInputRef}
           type="text"
           placeholder="Filter genres and moods..."
           value={searchQuery}

+ 14 - 3
src/components/landing/carousel-animation.tsx

@@ -4,7 +4,8 @@ import { useState, useEffect, useRef, useSyncExternalStore } from "react";
 import { getTMDBImageUrl } from "@/types/tmdb";
 import type { TMDBMovie } from "@/types/tmdb";
 import { TeaserCard } from "./teaser-card";
-import { GenreRollModal } from "./genre-roll-modal";
+import { GenreRollModal } from "@/components/dice/genre-roll-modal";
+import { EMOTION_TO_GENRE_MAP } from "@/lib/constants";
 
 interface ReelPoster {
   tmdb_id: number;
@@ -181,9 +182,19 @@ export function CarouselSection() {
 
   const handleRandomRoll = () => fetchAndRoll("/api/tmdb/popular");
 
-  const handleGenreRoll = (genreIds: number[]) => {
+  const handleGenreRoll = ({ genreIds, moodKeys }: { genreIds: number[]; moodKeys: string[] }) => {
     setModalOpen(false);
-    return fetchAndRoll(`/api/tmdb/discover?with_genres=${genreIds.join(",")}`);
+    // Resolve mood keys to genre IDs (both primary AND secondary, matching
+    // filterByGenresAndEmotions semantics) and union with explicit genre IDs.
+    const ids = new Set<number>(genreIds);
+    for (const moodKey of moodKeys) {
+      const mapping = EMOTION_TO_GENRE_MAP[moodKey];
+      if (mapping) {
+        for (const id of mapping.primary) ids.add(id);
+        for (const id of mapping.secondary) ids.add(id);
+      }
+    }
+    return fetchAndRoll(`/api/tmdb/discover?with_genres=${Array.from(ids).join(",")}`);
   };
 
   const handleModalClose = () => {