• MangaSearchPanel React component for searching and selecting AniList manga matches for a given Kenmei manga, with manual search and result selection features.

    Parameters

    Returns Element

    The rendered manga search panel React element.

    export function MangaSearchPanel({
    kenmeiManga,
    onClose,
    onSelectMatch,
    token,
    bypassCache,
    }: MangaSearchPanelProps) {
    const [searchQuery, setSearchQuery] = useState("");
    const [isSearching, setIsSearching] = useState(false);
    const [searchResults, setSearchResults] = useState<AniListManga[]>([]);
    const [error, setError] = useState<string | null>(null);
    const [selectedIndex, setSelectedIndex] = useState(-1);
    const [page, setPage] = useState(1);
    const [hasNextPage, setHasNextPage] = useState(false);

    const searchInputRef = useRef<HTMLInputElement>(null);
    const resultsContainerRef = useRef<HTMLDivElement>(null);

    // Style modifications to make everything larger
    const headerClasses = "text-xl font-medium"; // Increased from text-lg
    const titleClasses = "text-2xl font-semibold"; // Increased from text-lg

    // Need to prevent duplicate searches within a short time window
    const initiateSearch = (title: string) => {
    const now = Date.now();
    const currentMangaId = kenmeiManga?.id;

    // Don't search if:
    // 1. A search is already in progress
    // 2. We've searched for this manga ID very recently (within 2 seconds)
    // 3. This is the same manga ID as the last search
    if (
    searchTracker.searchInProgress ||
    (currentMangaId === searchTracker.lastMangaId &&
    now - searchTracker.lastSearchTime < 2000)
    ) {
    console.log(
    `🔍 Skipping duplicate search for "${title}" - searched recently or in progress`,
    );
    return;
    }

    // Update tracker before starting search
    searchTracker.lastMangaId = currentMangaId;
    searchTracker.lastSearchTime = now;
    searchTracker.searchInProgress = true;

    console.log(`🔍 Initiating search for "${title}"`);
    setSearchQuery(title);

    // Small delay to ensure state is set
    setTimeout(() => {
    handleSearch(title).finally(() => {
    searchTracker.searchInProgress = false;
    });
    }, 100);
    };

    useEffect(() => {
    // Focus the search input
    if (searchInputRef.current) {
    searchInputRef.current.focus();
    }

    // If we have a manga title, search for it
    if (kenmeiManga?.title) {
    initiateSearch(kenmeiManga.title);
    }
    }, [kenmeiManga?.id, kenmeiManga?.title]);

    useEffect(() => {
    setSelectedIndex(-1);
    }, [searchResults]);

    useEffect(() => {
    if (selectedIndex >= 0 && resultsContainerRef.current) {
    const selectedElement = resultsContainerRef.current.querySelector(
    `[data-index="${selectedIndex}"]`,
    ) as HTMLElement;
    if (selectedElement) {
    selectedElement.scrollIntoView({
    behavior: "smooth",
    block: "nearest",
    });
    }
    }
    }, [selectedIndex]);

    const handleSearch = async (query: string, pageNum: number = 1) => {
    if (!query.trim()) return;

    setIsSearching(true);
    setError(null);

    try {
    console.log(
    `🔎 Starting search for: "${query}" with bypassCache=${!!bypassCache}, page=${pageNum}`,
    );

    const startTime = performance.now();

    // Ensure manual searches always show results by setting exactMatchingOnly to false
    const searchConfig = {
    bypassCache: !!bypassCache,
    maxSearchResults: 30,
    searchPerPage: 50,
    exactMatchingOnly: false, // Always false for manual searches
    };

    console.log(`🔎 Search config:`, searchConfig);

    const results = await searchMangaByTitle(query, token, searchConfig);
    const endTime = performance.now();

    console.log(
    `🔎 Search completed in ${(endTime - startTime).toFixed(2)}ms for "${query}"`,
    );
    console.log(
    `🔎 Search returned ${results.length} results for "${query}"`,
    );

    // Log the actual titles received
    if (results.length > 0) {
    console.log(
    `🔎 Titles received:`,
    results.map((m) => ({
    title: m.manga.title?.romaji || m.manga.title?.english || "unknown",
    confidence: m.confidence.toFixed(1),
    id: m.manga.id,
    })),
    );
    }

    if (results.length === 0) {
    console.log(
    `⚠️ No results found for "${query}" - this could indicate a cache or display issue`,
    );
    }

    if (pageNum === 1) {
    console.log(`🔎 Resetting search results for "${query}"`);
    setSearchResults(results.map((match) => match.manga));
    } else {
    console.log(
    `🔎 Appending ${results.length} results to existing ${searchResults.length} results`,
    );
    setSearchResults((prev) => [
    ...prev,
    ...results.map((match) => match.manga),
    ]);
    }

    // Always set hasNextPage to true if we got results (could be more)
    setHasNextPage(results.length >= 10);
    setPage(pageNum);

    console.log(
    `🔎 UI state updated: searchResults.length=${results.length}, hasNextPage=${results.length >= 10}, page=${pageNum}`,
    );
    } catch (error) {
    console.error("Error searching manga:", error);
    setError("Failed to search for manga. Please try again.");
    if (pageNum === 1) {
    setSearchResults([]);
    console.log(`⚠️ Search error - cleared results`);
    }
    } finally {
    setIsSearching(false);
    console.log(`🔎 Search complete, isSearching set to false`);
    }
    };

    const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setPage(1);
    handleSearch(searchQuery);
    };

    const loadMoreResults = () => {
    handleSearch(searchQuery, page + 1);
    };

    const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
    onClose();
    return;
    }

    if (e.target === searchInputRef.current) {
    if (e.key === "Enter") {
    e.preventDefault();
    handleSubmit(e);
    } else if (e.key === "ArrowDown" && searchResults.length > 0) {
    e.preventDefault();
    setSelectedIndex(0);
    }
    return;
    }

    if (searchResults.length > 0) {
    if (e.key === "ArrowDown") {
    e.preventDefault();
    setSelectedIndex((prev) =>
    Math.min(prev + 1, searchResults.length - 1),
    );
    } else if (e.key === "ArrowUp") {
    e.preventDefault();
    setSelectedIndex((prev) => {
    const newIndex = prev - 1;
    if (newIndex < 0) {
    searchInputRef.current?.focus();
    return -1;
    }
    return newIndex;
    });
    } else if (e.key === "Enter" && selectedIndex >= 0) {
    e.preventDefault();
    onSelectMatch(searchResults[selectedIndex]);
    }
    }
    };

    const handleSelectResult = (manga: AniListManga, index: number) => {
    setSelectedIndex(index);
    onSelectMatch(manga);
    };

    /**
    * Displays a panel for searching and selecting AniList manga matches for a given Kenmei manga, supporting manual search and result selection.
    *
    * @param props - The props for the MangaSearchPanel component.
    * @returns The rendered manga search panel React element.
    * @source
    * @example
    * ```tsx
    * <MangaSearchPanel
    * kenmeiManga={manga}
    * onClose={handleClose}
    * onSelectMatch={handleSelect}
    * token={token}
    * bypassCache={false}
    * />
    * ```
    */
    return (
    <div
    className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md dark:border-gray-700 dark:bg-gray-800"
    onKeyDown={handleKeyDown}
    role="dialog"
    aria-labelledby="search-title"
    aria-modal="true"
    >
    <div className="border-b border-gray-200 bg-gray-50 p-5 dark:border-gray-700 dark:bg-gray-800">
    <div className="flex items-center justify-between">
    <div className="flex items-center">
    <button
    onClick={onClose}
    className="mr-4 rounded-md p-2 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
    aria-label="Go back"
    >
    <ArrowLeft size={24} aria-hidden="true" />
    </button>
    <h2
    id="search-title"
    className={`${headerClasses} text-gray-900 dark:text-white`}
    >
    Search for manga
    </h2>
    </div>
    <button
    onClick={onClose}
    className="rounded-md p-2 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
    aria-label="Close search panel"
    >
    <X size={24} aria-hidden="true" />
    </button>
    </div>
    </div>

    {kenmeiManga && (
    <div className="border-b border-gray-200 bg-blue-50 p-5 dark:border-gray-700 dark:bg-blue-900/20">
    <h3 className="mb-2 text-lg font-medium text-gray-900 dark:text-white">
    Looking for a match for:
    </h3>
    <p className={`${titleClasses} text-blue-700 dark:text-blue-300`}>
    {kenmeiManga.title}
    </p>
    <p className="mt-2 text-base text-gray-600 dark:text-gray-400">
    {kenmeiManga.status} • {kenmeiManga.chapters_read} chapters read
    {kenmeiManga.score > 0 && ` • Score: ${kenmeiManga.score}/10`}
    </p>
    </div>
    )}

    <div className="border-b border-gray-200 p-5 dark:border-gray-700">
    <form onSubmit={handleSubmit} className="flex items-center gap-3">
    <div className="relative flex-1">
    <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
    <Search className="h-6 w-6 text-gray-400" aria-hidden="true" />
    </div>
    <input
    ref={searchInputRef}
    type="text"
    className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-3 pl-12 text-base text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
    placeholder="Search for a manga title..."
    value={searchQuery}
    onChange={(e) => setSearchQuery(e.target.value)}
    disabled={isSearching}
    aria-label="Search manga title"
    autoComplete="off"
    />
    </div>
    <button
    type="submit"
    className="inline-flex items-center rounded-lg bg-blue-700 px-5 py-3 text-base font-medium text-white hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 focus:outline-none disabled:bg-blue-400 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
    disabled={isSearching || !searchQuery.trim()}
    aria-label={isSearching ? "Searching..." : "Search for manga"}
    >
    {isSearching ? (
    <>
    <Loader2
    className="mr-2 h-5 w-5 animate-spin"
    aria-hidden="true"
    />
    Searching...
    </>
    ) : (
    "Search"
    )}
    </button>
    </form>
    </div>

    <div
    ref={resultsContainerRef}
    className="flex-1 overflow-y-auto p-5"
    role="region"
    aria-label="Search results"
    aria-live="polite"
    >
    {error && (
    <div
    className="mb-5 rounded-md bg-red-50 p-4 text-base text-red-700 dark:bg-red-900/20 dark:text-red-400"
    role="alert"
    >
    <p>{error}</p>
    </div>
    )}

    {searchResults.length === 0 && !isSearching && !error && (
    <div className="text-center text-lg text-gray-500 dark:text-gray-400">
    {searchQuery.trim()
    ? "No results found"
    : "Enter a search term to find manga"}
    </div>
    )}

    <div className="space-y-5">
    {searchResults.map((result, index) => {
    const mangaId = result.id;
    const uniqueKey = mangaId
    ? `manga-${mangaId}`
    : `manga-${index}-${result.title?.romaji?.replace(/\s/g, "") || "unknown"}`;

    return (
    <div
    key={uniqueKey}
    data-index={index}
    className={`relative flex cursor-pointer flex-col space-y-3 rounded-lg border p-5 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800 ${
    index === selectedIndex
    ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
    : "border-gray-200"
    }`}
    onClick={() => handleSelectResult(result, index)}
    tabIndex={0}
    role="button"
    aria-pressed={index === selectedIndex}
    onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
    e.preventDefault();
    handleSelectResult(result, index);
    }
    }}
    >
    <div className="flex w-full items-start space-x-5">
    {result.coverImage?.medium && (
    <img
    src={result.coverImage.medium}
    alt={`Cover for ${result.title?.english || result.title?.romaji || "manga"}`}
    className="h-40 w-28 object-cover"
    loading="lazy"
    />
    )}

    <div className="flex-1 space-y-2">
    <h3 className="text-xl font-semibold text-gray-900 dark:text-white">
    {result.title?.english ||
    result.title?.romaji ||
    "Unknown Title"}
    </h3>

    {result.title?.romaji &&
    result.title.romaji !== result.title.english && (
    <p className="text-base text-gray-600 dark:text-gray-400">
    {result.title.romaji}
    </p>
    )}

    <div className="flex flex-wrap gap-2 pt-2">
    {result.format && (
    <span className="inline-flex items-center rounded-md bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
    {result.format.replace("_", " ")}
    </span>
    )}

    {result.status && (
    <span className="inline-flex items-center rounded-md bg-green-100 px-3 py-1 text-sm font-medium text-green-800 dark:bg-green-900/30 dark:text-green-300">
    {result.status.replace("_", " ")}
    </span>
    )}

    {result.chapters && (
    <span className="inline-flex items-center rounded-md bg-purple-100 px-3 py-1 text-sm font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
    {result.chapters} chapters
    </span>
    )}
    </div>

    <div className="flex flex-wrap gap-2 pt-2">
    {result.genres?.slice(0, 3).map((genre, i) => (
    <span
    key={`${uniqueKey}-genre-${i}`}
    className="inline-flex items-center rounded-md bg-gray-100 px-3 py-1 text-sm font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-300"
    >
    {genre}
    </span>
    ))}
    {result.genres && result.genres.length > 3 && (
    <span className="inline-flex items-center rounded-md bg-gray-100 px-3 py-1 text-sm font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-300">
    +{result.genres.length - 3} more
    </span>
    )}
    </div>
    </div>

    <div className="ml-auto flex flex-col items-end justify-between self-stretch">
    <a
    href={`https://anilist.co/manga/${result.id}`}
    target="_blank"
    rel="noopener noreferrer"
    className="rounded p-2 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
    aria-label="View on AniList"
    onClick={(e) => e.stopPropagation()}
    >
    <ExternalLink size={20} aria-hidden="true" />
    </a>

    <button
    className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-base font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-blue-600 dark:hover:bg-blue-700"
    onClick={(e) => {
    e.stopPropagation();
    handleSelectResult(result, index);
    }}
    aria-label="Select this manga match"
    >
    <Check size={18} className="mr-2" aria-hidden="true" />{" "}
    Select
    </button>
    </div>
    </div>
    </div>
    );
    })}

    {hasNextPage && (
    <button
    className="w-full rounded-md border border-gray-200 bg-white py-3 text-center text-base font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={loadMoreResults}
    disabled={isSearching}
    >
    {isSearching ? (
    <>
    <Loader2
    className="mr-2 inline h-5 w-5 animate-spin"
    aria-hidden="true"
    />
    Loading more...
    </>
    ) : (
    "Load more results"
    )}
    </button>
    )}
    </div>
    </div>
    </div>
    );
    }