• MangaMatchingPanel React component for reviewing, filtering, sorting, and managing manga match results, including manual search, acceptance, rejection, and alternative selection.

    Parameters

    Returns Element

    The rendered manga matching panel React element.

    export function MangaMatchingPanel({
    matches,
    onManualSearch,
    onAcceptMatch,
    onRejectMatch,
    onSelectAlternative,
    onResetToPending,
    searchQuery,
    onSetMatchedToPending,
    }: Readonly<MangaMatchingPanelProps>) {
    const [currentPage, setCurrentPage] = useState(1);
    const [statusFilters, setStatusFilters] = useState<StatusFiltersState>({
    matched: true,
    pending: true,
    manual: true,
    skipped: true,
    });
    const [searchTerm, setSearchTerm] = useState("");
    const searchInputRef = useRef<HTMLInputElement>(null);
    const containerRef = useRef<HTMLDivElement>(null);
    const lastExternalSearchQuery = useRef<string | undefined>(undefined);

    type SortField = "title" | "status" | "confidence" | "chapters_read";
    const [sortOption, setSortOption] = useState<{
    field: SortField;
    direction: "asc" | "desc";
    }>({ field: "title", direction: "asc" });

    const itemsPerPage = 10;
    const [isSkippingEmptyMatches, setIsSkippingEmptyMatches] = useState(false);
    const [isAcceptingAllMatches, setIsAcceptingAllMatches] = useState(false);
    const [isReSearchingNoMatches, setIsReSearchingNoMatches] = useState(false);
    const [isResettingSkippedToPending, setIsResettingSkippedToPending] =
    useState(false);
    const [isResettingMatchedToPending] = useState(false);

    // Add state for adult content settings and blur management
    const [blurAdultContent, setBlurAdultContent] = useState(true);
    const [unblurredImages, setUnblurredImages] = useState<Set<string>>(
    new Set(),
    );

    // Add state for Comick search setting (disabled - Comick temporarily unavailable)
    // eslint-disable-next-line
    const [enableComickSearch, setEnableComickSearch] = useState(false);
    // Add state for MangaDex search setting
    const [enableMangaDexSearch, setEnableMangaDexSearch] = useState(true);

    // Load blur settings from match config
    useEffect(() => {
    const loadBlurSettings = async () => {
    const matchConfig = getMatchConfig();
    setBlurAdultContent(matchConfig.blurAdultContent);
    // Comick is temporarily disabled, keep it false
    setEnableComickSearch(false);
    setEnableMangaDexSearch(matchConfig.enableMangaDexSearch);
    };
    loadBlurSettings();
    }, []);

    // Helper functions for adult content handling
    const isAdultContent = (manga: AniListManga | undefined | null) => {
    return manga?.isAdult === true;
    };

    const shouldBlurImage = (mangaId: string) => {
    return blurAdultContent && !unblurredImages.has(mangaId);
    };

    const toggleImageBlur = (mangaId: string) => {
    setUnblurredImages((prev) => {
    const newSet = new Set(prev);
    if (newSet.has(mangaId)) {
    newSet.delete(mangaId);
    } else {
    newSet.add(mangaId);
    }
    return newSet;
    });
    };

    // Handler for toggling Comick search setting
    const handleComickSearchToggle = async (enabled: boolean) => {
    setEnableComickSearch(enabled);
    try {
    const currentConfig = getMatchConfig();
    const updatedConfig = {
    ...currentConfig,
    enableComickSearch: enabled,
    };
    saveMatchConfig(updatedConfig);
    } catch (error) {
    console.error("Failed to save Comick search setting:", error);
    // Revert the state if saving failed
    setEnableComickSearch(!enabled);
    }
    };

    // Handler for toggling MangaDex search setting
    const handleMangaDexSearchToggle = async (enabled: boolean) => {
    setEnableMangaDexSearch(enabled);
    try {
    const currentConfig = getMatchConfig();
    const updatedConfig = {
    ...currentConfig,
    enableMangaDexSearch: enabled,
    };
    saveMatchConfig(updatedConfig);
    } catch (error) {
    console.error("Failed to save MangaDex search setting:", error);
    // Revert the state if saving failed
    setEnableMangaDexSearch(!enabled);
    }
    };

    // Handler for opening external links in the default browser
    const handleOpenExternal = (url: string) => (e: React.MouseEvent) => {
    e.preventDefault();
    if (globalThis.electronAPI?.shell?.openExternal) {
    globalThis.electronAPI.shell.openExternal(url);
    } else {
    // Fallback to regular link behavior if not in Electron
    globalThis.open(url, "_blank", "noopener,noreferrer");
    }
    };

    // Process matches to filter out Light Novels from alternatives
    const processedMatches = matches.map((match) => {
    // Ensure manga has an ID - if missing, generate one based on title
    if (match.kenmeiManga.id === undefined) {
    // Create a simple hash from the title
    const generatedId = match.kenmeiManga.title
    .split("")
    .reduce(
    (hash, char) => (hash << 5) - hash + (char.codePointAt(0) ?? 0),
    0,
    );
    match = {
    ...match,
    kenmeiManga: {
    ...match.kenmeiManga,
    id: Math.abs(generatedId),
    },
    };
    }

    // Filter out Light Novels from anilistMatches
    const filteredAltMatches = match.anilistMatches
    ? match.anilistMatches.filter(
    (m) =>
    m.manga &&
    m.manga.format !== "NOVEL" &&
    m.manga.format !== "LIGHT_NOVEL",
    )
    : [];

    // If the selected match is a Light Novel, clear it
    const newSelectedMatch =
    match.selectedMatch &&
    (match.selectedMatch.format === "NOVEL" ||
    match.selectedMatch.format === "LIGHT_NOVEL")
    ? undefined
    : match.selectedMatch;

    // Return a new object with filtered matches
    return {
    ...match,
    anilistMatches: filteredAltMatches,
    selectedMatch: newSelectedMatch,
    };
    });

    // Filter and search matches
    const filteredMatches = processedMatches.filter((match) => {
    // Sanity check - skip entries with no ID
    if (match.kenmeiManga.id === undefined) return false;

    // Apply status filters
    const statusMatch =
    (match.status === "matched" && statusFilters.matched) ||
    (match.status === "pending" && statusFilters.pending) ||
    (match.status === "manual" && statusFilters.manual) ||
    (match.status === "skipped" && statusFilters.skipped);

    // Then apply search term if any
    const searchMatch =
    !searchTerm ||
    match.kenmeiManga.title
    .toLowerCase()
    .includes(searchTerm.toLowerCase()) ||
    match.selectedMatch?.title?.english
    ?.toLowerCase()
    .includes(searchTerm.toLowerCase()) ||
    match.selectedMatch?.title?.romaji
    ?.toLowerCase()
    .includes(searchTerm.toLowerCase());

    return statusMatch && searchMatch;
    });

    // Sort the filtered matches
    const sortedMatches = [...filteredMatches].sort((a, b) => {
    // Declare variables outside switch to avoid linter errors
    let titleA: string, titleB: string;
    let statusA: number, statusB: number;
    let confidenceA: number, confidenceB: number;
    let chaptersA: number, chaptersB: number;

    // Define status priority for sorting (matched > manual > conflict > pending > skipped)
    const statusPriority: Record<string, number> = {
    matched: 1,
    manual: 2,
    conflict: 3,
    pending: 4,
    skipped: 5,
    };

    switch (sortOption.field) {
    case "title":
    titleA = a.kenmeiManga.title.toLowerCase();
    titleB = b.kenmeiManga.title.toLowerCase();
    return sortOption.direction === "asc"
    ? titleA.localeCompare(titleB)
    : titleB.localeCompare(titleA);

    case "status":
    statusA = statusPriority[a.status] || 999;
    statusB = statusPriority[b.status] || 999;
    return sortOption.direction === "asc"
    ? statusA - statusB
    : statusB - statusA;

    case "confidence":
    // Get confidence scores
    // Entries with actual matches but 0 confidence should rank higher than entries with no matches at all
    confidenceA =
    a.anilistMatches?.length && a.anilistMatches.length > 0
    ? (a.anilistMatches[0].confidence ?? 0)
    : -1; // No matches at all should be lowest

    confidenceB =
    b.anilistMatches?.length && b.anilistMatches.length > 0
    ? (b.anilistMatches[0].confidence ?? 0)
    : -1; // No matches at all should be lowest

    return sortOption.direction === "asc"
    ? confidenceA - confidenceB
    : confidenceB - confidenceA;

    case "chapters_read":
    chaptersA = a.kenmeiManga.chapters_read || 0;
    chaptersB = b.kenmeiManga.chapters_read || 0;
    return sortOption.direction === "asc"
    ? chaptersA - chaptersB
    : chaptersB - chaptersA;

    default:
    return 0;
    }
    });

    // Pagination logic
    const totalPages = Math.max(
    1,
    Math.ceil(sortedMatches.length / itemsPerPage),
    );
    const currentMatches = sortedMatches.slice(
    (currentPage - 1) * itemsPerPage,
    currentPage * itemsPerPage,
    );

    // Auto-adjust current page if filters change
    useEffect(() => {
    // If current page is out of bounds, adjust it
    if (currentPage > totalPages) {
    setCurrentPage(Math.max(1, totalPages));
    }
    }, [statusFilters, searchTerm, totalPages, currentPage]);

    // Focus search input when pressing Ctrl+F
    useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && e.key === "f") {
    e.preventDefault();
    searchInputRef.current?.focus();
    }
    };

    globalThis.addEventListener("keydown", handleKeyDown);
    return () => globalThis.removeEventListener("keydown", handleKeyDown);
    }, []);

    // Count statistics
    const matchStats = {
    total: matches.length,
    matched: matches.filter((m) => m.status === "matched").length,
    pending: matches.filter((m) => m.status === "pending").length,
    manual: matches.filter((m) => m.status === "manual").length,
    skipped: matches.filter((m) => m.status === "skipped").length,
    };

    // Handle pagination
    const goToPage = (page: number) => {
    if (page < 1) page = 1;
    if (page > totalPages) page = totalPages;
    setCurrentPage(page);
    };

    // Add keyboard navigation for pagination
    useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
    // Skip if we're in an input field
    if (
    e.target instanceof HTMLInputElement ||
    e.target instanceof HTMLTextAreaElement
    ) {
    return;
    }

    // Handle arrow keys and Home/End keys
    if (e.key === "ArrowLeft" && currentPage > 1) {
    goToPage(currentPage - 1);
    } else if (e.key === "ArrowRight" && currentPage < totalPages) {
    goToPage(currentPage + 1);
    } else if (e.key === "Home" && currentPage > 1) {
    goToPage(1);
    } else if (e.key === "End" && currentPage < totalPages) {
    goToPage(totalPages);
    }
    };

    // Add event listener
    document.addEventListener("keydown", handleKeyDown);

    // Cleanup
    return () => {
    document.removeEventListener("keydown", handleKeyDown);
    };
    }, [currentPage, totalPages]);

    // Sync external searchQuery with local searchTerm
    useEffect(() => {
    if (
    searchQuery !== undefined &&
    searchQuery.trim() !== "" &&
    searchQuery !== lastExternalSearchQuery.current
    ) {
    setSearchTerm(searchQuery);
    lastExternalSearchQuery.current = searchQuery;
    }
    }, [searchQuery]);

    // Handle sort change
    const handleSortChange = (field: SortField) => {
    setSortOption((prev) => {
    // If clicking the same field, toggle direction
    if (prev.field === field) {
    return {
    ...prev,
    direction: prev.direction === "asc" ? "desc" : "asc",
    };
    }
    // If clicking a new field, default to ascending for title, descending for others
    return {
    field,
    direction: field === "title" ? "asc" : "desc",
    };
    });
    };

    // Function to render sort indicator
    const renderSortIndicator = (field: SortField) => {
    if (sortOption.field !== field) return null;

    return (
    <span className="ml-1 text-xs">
    {sortOption.direction === "asc" ? "▲" : "▼"}
    </span>
    );
    };

    // Confidence badge extracted to separate component

    // Helper function to format status text nicely - moved outside for reuse
    const formatStatusText = (status: string | undefined): string => {
    if (!status) return "Unknown";

    // Handle cases with underscores or spaces
    return status
    .split(/[_\s]+/) // Split by underscores or spaces
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
    .join(" ");
    };

    // Handle keyboard navigation for item selection
    const handleKeyDown = (e: React.KeyboardEvent, callback: () => void) => {
    if (e.key === "Enter" || e.key === " ") {
    e.preventDefault();
    callback();
    }
    };

    // Function to skip all pending matches with no results
    const handleSkipEmptyMatches = () => {
    // Set processing state to disable the button
    setIsSkippingEmptyMatches(true);

    // Find all pending manga with no matches
    const pendingWithNoMatches = matches.filter(
    (match) =>
    match.status === "pending" &&
    (!match.anilistMatches || match.anilistMatches.length === 0),
    );

    console.log(
    `Skipping ${pendingWithNoMatches.length} pending manga with no matches`,
    );

    // Skip all matches at once if possible
    if (pendingWithNoMatches.length > 0 && onRejectMatch) {
    // Create a single batched update by using a custom handler
    const batchedReject = matches.map((match) => {
    // Only modify the matches that need to be skipped
    if (
    match.status === "pending" &&
    (!match.anilistMatches || match.anilistMatches.length === 0)
    ) {
    // Return a modified version with skipped status
    return {
    ...match,
    status: "skipped" as const,
    selectedMatch: undefined,
    matchDate: new Date(),
    };
    }
    // Return the original for all other matches
    return match;
    });

    // Pass the full array with modifications to the parent
    // Special flag to indicate this is a batch operation
    const batchOperation = {
    isBatchOperation: true,
    matches: batchedReject,
    };

    // @ts-expect-error - We're adding a special property for the batch handler to recognize
    onRejectMatch(batchOperation);

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsSkippingEmptyMatches(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsSkippingEmptyMatches(false);
    }
    };

    // Get count of pending matches with no results
    const emptyMatchesCount = matches.filter(
    (match) =>
    match.status === "pending" &&
    (!match.anilistMatches || match.anilistMatches.length === 0),
    ).length;

    // Function to accept all pending matches with main matches
    const handleAcceptAllPendingMatches = () => {
    // Set processing state to disable the button
    setIsAcceptingAllMatches(true);

    // Find all pending manga with valid main matches
    const pendingWithMatches = matches.filter(
    (match) =>
    match.status === "pending" &&
    match.anilistMatches &&
    match.anilistMatches.length > 0,
    );

    console.log(
    `Accepting ${pendingWithMatches.length} pending manga with matches`,
    );

    // Accept all matches at once if possible
    if (pendingWithMatches.length > 0 && onAcceptMatch) {
    // Create a single batched update
    const batchedAccept = matches.map((match) => {
    // Only modify the matches that need to be accepted
    if (
    match.status === "pending" &&
    match.anilistMatches &&
    match.anilistMatches.length > 0
    ) {
    // Return a modified version with matched status
    return {
    ...match,
    status: "matched" as const,
    selectedMatch: match.anilistMatches[0].manga,
    matchDate: new Date(),
    };
    }
    // Return the original for all other matches
    return match;
    });

    // Pass the full array with modifications to the parent
    // Special flag to indicate this is a batch operation
    const batchOperation = {
    isBatchOperation: true,
    matches: batchedAccept,
    };

    // @ts-expect-error - We're adding a special property for the batch handler to recognize
    onAcceptMatch(batchOperation);

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsAcceptingAllMatches(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsAcceptingAllMatches(false);
    }
    };

    // Get count of pending matches with valid matches
    const pendingMatchesCount = matches.filter(
    (match) =>
    match.status === "pending" &&
    match.anilistMatches &&
    match.anilistMatches.length > 0,
    ).length;

    // Function to handle re-searching all manga without matches regardless of status
    const handleReSearchNoMatches = () => {
    // Set processing state to disable the button
    setIsReSearchingNoMatches(true);

    // Find all manga without any matches regardless of status
    const mangaWithoutMatches = matches.filter(
    (match) => !match.anilistMatches || match.anilistMatches.length === 0,
    );

    console.log(
    `Re-searching ${mangaWithoutMatches.length} manga without any matches`,
    );

    if (mangaWithoutMatches.length > 0) {
    // Extract the Kenmei manga objects from the matches
    const kenmeiMangaToResearch = mangaWithoutMatches.map(
    (match) => match.kenmeiManga,
    );

    // Create a custom event to trigger the re-search process at the page level
    // This allows us to use the same efficient batch processing as the "Fresh Search" button
    const customEvent = new CustomEvent("reSearchEmptyMatches", {
    detail: {
    mangaToResearch: kenmeiMangaToResearch,
    },
    });

    // Dispatch the event to be handled by the MatchingPage component
    globalThis.dispatchEvent(customEvent);

    // Reset processing state after a short delay
    setTimeout(() => {
    setIsReSearchingNoMatches(false);
    }, 1000);
    } else {
    // Reset processing state if no matching items found
    setIsReSearchingNoMatches(false);
    }
    };

    // Get count of manga without any matches
    const noMatchesCount = matches.filter(
    (match) => !match.anilistMatches || match.anilistMatches.length === 0,
    ).length;

    // Function to handle resetting all skipped manga to pending
    const handleResetSkippedToPending = () => {
    // Set processing state to disable the button
    setIsResettingSkippedToPending(true);

    // Find all skipped manga
    const skippedManga = matches.filter((match) => match.status === "skipped");

    console.log(
    `Resetting ${skippedManga.length} skipped manga to pending status`,
    );

    // Reset all these manga to pending status
    if (skippedManga.length > 0 && onResetToPending) {
    // Create a batched update by modifying the matches
    const batchedReset = matches.map((match) => {
    // Only modify the matches that are skipped
    if (match.status === "skipped") {
    // Return a modified version with pending status
    return {
    ...match,
    status: "pending" as const,
    selectedMatch: undefined,
    matchDate: new Date(),
    };
    }
    // Return the original for all other matches
    return match;
    });

    // Pass the full array with modifications to the parent
    // Special flag to indicate this is a batch operation
    const batchOperation = {
    isBatchOperation: true,
    matches: batchedReset,
    };

    // @ts-expect-error - We're adding a special property for the batch handler to recognize
    onResetToPending(batchOperation);

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsResettingSkippedToPending(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsResettingSkippedToPending(false);
    }
    };

    // Get count of skipped manga
    const skippedMangaCount = matches.filter(
    (match) => match.status === "skipped",
    ).length;

    // Count matched manga for bulk reset label
    const matchedCount = matches.filter((m) => m.status === "matched").length;

    return (
    <div
    className="flex flex-col space-y-4"
    ref={containerRef}
    tabIndex={-1} // Make div focusable but not in tab order
    >
    <MatchStatisticsCard
    matchStats={matchStats}
    noMatchesCount={noMatchesCount}
    searchTerm={searchTerm}
    onSearchTermChange={(value) => setSearchTerm(value)}
    searchInputRef={searchInputRef}
    />

    <MatchBulkActions
    emptyMatchesCount={emptyMatchesCount}
    onSkipEmptyMatches={handleSkipEmptyMatches}
    isSkippingEmptyMatches={isSkippingEmptyMatches}
    noMatchesCount={noMatchesCount}
    onReSearchNoMatches={handleReSearchNoMatches}
    isReSearchingNoMatches={isReSearchingNoMatches}
    skippedMangaCount={skippedMangaCount}
    onResetSkippedToPending={handleResetSkippedToPending}
    isResettingSkippedToPending={isResettingSkippedToPending}
    pendingMatchesCount={pendingMatchesCount}
    onAcceptAllPendingMatches={handleAcceptAllPendingMatches}
    isAcceptingAllMatches={isAcceptingAllMatches}
    onSetMatchedToPending={onSetMatchedToPending}
    isResettingMatchedToPending={isResettingMatchedToPending}
    matchedCount={matchedCount}
    />

    <MatchFilterControls
    statusFilters={statusFilters}
    setStatusFilters={setStatusFilters}
    matchStats={matchStats}
    />

    <AlternativeSearchSettingsCard
    enableMangaDexSearch={enableMangaDexSearch}
    onComickSearchToggle={handleComickSearchToggle}
    onMangaDexSearchToggle={handleMangaDexSearchToggle}
    />

    {/* Sort options */}
    <Card className="relative mb-4 overflow-hidden rounded-3xl border border-white/40 bg-white/75 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/70">
    <div className="pointer-events-none absolute top-0 -left-16 h-48 w-48 rounded-full bg-indigo-400/15 blur-3xl" />
    <div className="pointer-events-none absolute -right-20 bottom-0 h-56 w-56 rounded-full bg-blue-400/15 blur-3xl" />
    <CardHeader className="relative z-10 flex flex-col gap-2 border-b border-white/40 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800/60">
    <div className="flex items-center gap-3">
    <div className="flex h-9 w-9 items-center justify-center rounded-full bg-indigo-500/10 text-indigo-500">
    <ArrowUpDown className="h-4 w-4" />
    </div>
    <div>
    <CardTitle className="text-base font-semibold text-slate-900 dark:text-white">
    Sort Priorities
    </CardTitle>
    <p className="text-xs text-slate-500 dark:text-slate-400">
    Tap a mode to focus your review queue. Tap again to flip
    direction.
    </p>
    </div>
    </div>
    <Badge className="rounded-full border border-white/40 bg-white/40 px-3 py-1 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
    {sortOption.field.toUpperCase()} ·{" "}
    {sortOption.direction === "asc" ? "Ascending" : "Descending"}
    </Badge>
    </CardHeader>
    <CardContent className="relative z-10 p-5">
    <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
    {[
    {
    field: "title" as const,
    label: "Title",
    helper: "Alphabetical preview",
    icon: Type,
    accent: "from-slate-400/20 via-slate-500/10 to-transparent",
    },
    {
    field: "status" as const,
    label: "Status",
    helper: "Group by review workflow",
    icon: ListFilter,
    accent: "from-emerald-400/20 via-emerald-500/10 to-transparent",
    },
    {
    field: "confidence" as const,
    label: "Confidence",
    helper: "Highest certainty first",
    icon: Sparkles,
    accent: "from-violet-400/20 via-violet-500/10 to-transparent",
    },
    {
    field: "chapters_read" as const,
    label: "Chapters Read",
    helper: "Prioritize deep progress",
    icon: BookOpen,
    accent: "from-amber-400/20 via-amber-500/10 to-transparent",
    },
    ].map(({ field, label, helper, icon: Icon, accent }) => {
    const isActive = sortOption.field === field;
    let directionLabel: string;
    if (isActive) {
    directionLabel =
    sortOption.direction === "asc" ? "Ascending" : "Descending";
    } else {
    directionLabel = "Tap to sort";
    }

    return (
    <button
    key={field}
    type="button"
    onClick={() => handleSortChange(field)}
    className={cn(
    "group relative overflow-hidden rounded-2xl border border-white/40 bg-white/65 p-4 text-left shadow-md transition-all hover:translate-y-[-2px] hover:border-white/60 hover:bg-white/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 dark:border-slate-800/60 dark:bg-slate-900/65 dark:hover:border-slate-700",
    isActive &&
    "ring-offset-background ring-2 ring-indigo-400 ring-offset-2",
    )}
    >
    <div
    className={cn(
    "absolute inset-0 bg-gradient-to-br opacity-40 transition-opacity duration-300 group-hover:opacity-70",
    accent,
    )}
    />
    <div className="relative flex flex-col gap-3">
    <div className="flex items-center justify-between">
    <div className="flex items-center gap-2">
    <span
    className={cn(
    "flex h-9 w-9 items-center justify-center rounded-full border border-white/50 bg-white/70 text-slate-600 dark:border-slate-700 dark:bg-slate-900/70",
    isActive && "border-indigo-400/50 text-indigo-500",
    )}
    >
    <Icon className="h-4 w-4" />
    </span>
    <span className="text-sm font-semibold text-slate-900 dark:text-white">
    {label}
    </span>
    </div>
    <Badge
    variant="secondary"
    className="flex items-center gap-1 rounded-full border border-white/40 bg-white/60 px-2.5 py-0.5 text-[11px] font-semibold tracking-wide text-slate-500 uppercase dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300"
    >
    {directionLabel}
    {renderSortIndicator(field)}
    </Badge>
    </div>
    <p className="text-xs text-slate-500 dark:text-slate-400">
    {helper}
    </p>
    </div>
    </button>
    );
    })}
    </div>
    </CardContent>
    </Card>

    {/* Confidence accuracy notice */}
    <div className="relative mb-4 overflow-hidden rounded-3xl border border-amber-400/40 bg-amber-50/80 p-5 shadow-xl shadow-amber-500/10 backdrop-blur dark:border-amber-500/30 dark:bg-amber-900/25">
    <div className="pointer-events-none absolute -top-20 left-10 h-48 w-48 rounded-full bg-amber-400/25 blur-3xl" />
    <div className="pointer-events-none absolute right-8 -bottom-16 h-40 w-40 rounded-full bg-red-400/15 blur-3xl" />
    <div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
    <div className="flex items-start gap-3">
    <span className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500/30 bg-amber-100/70 text-amber-600 shadow-inner dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-200">
    <ShieldAlert className="h-5 w-5" />
    </span>
    <div className="space-y-2">
    <h3 className="text-base font-semibold text-amber-900 dark:text-amber-100">
    Confidence percentages are advisory
    </h3>
    <p className="text-sm text-amber-900/90 dark:text-amber-100/90">
    Treat the score as a hintnot a guarantee. Always glance over
    matches with similar titles, alternate editions, or multiple
    adaptations.
    </p>
    <p className="text-sm text-amber-900/90 dark:text-amber-100/90">
    Found a confidence outlier? Let me know so I can improve the
    matching for everyone.
    </p>
    </div>
    </div>
    <div className="flex flex-col gap-3 md:items-end">
    <Badge className="rounded-full border border-amber-500/20 bg-amber-200/70 px-3 py-1 text-xs font-semibold tracking-wide text-amber-700 uppercase dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-100">
    Manual review recommended
    </Badge>
    <a
    href="https://github.com/RLAlpha49/KenmeiToAnilist/issues"
    target="_blank"
    rel="noopener noreferrer"
    onClick={handleOpenExternal(
    "https://github.com/RLAlpha49/KenmeiToAnilist/issues",
    )}
    className="inline-flex items-center gap-2 rounded-full border border-amber-500/40 bg-transparent px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-500/10 hover:text-amber-800 dark:border-amber-500/40 dark:text-amber-200 dark:hover:bg-amber-500/20"
    >
    <AlertTriangle className="h-4 w-4" />
    Report a mismatch
    </a>
    </div>
    </div>
    </div>

    {/* Match list */}
    <div className="space-y-6" aria-live="polite">
    {currentMatches.length > 0 ? (
    <AnimatePresence mode="popLayout">
    {currentMatches.map((match, index) => {
    // Generate a unique key using index as fallback when ID is undefined
    const uniqueKey = match.kenmeiManga.id
    ? `${match.kenmeiManga.id}-${match.status}`
    : `index-${index}-${match.status}-${match.kenmeiManga.title?.replaceAll(" ", "_") || "unknown"}`;

    // Extract border color class for clarity
    let borderColorClass = "";
    if (match.status === "matched") {
    borderColorClass =
    "border-emerald-300/70 dark:border-emerald-500/60";
    } else if (match.status === "manual") {
    borderColorClass = "border-sky-300/70 dark:border-sky-500/60";
    } else if (match.status === "skipped") {
    borderColorClass = "border-rose-300/70 dark:border-rose-500/60";
    } else {
    borderColorClass =
    "border-slate-200/80 dark:border-slate-700/70";
    }

    // Extract status color for the indicator
    let statusBgColorClass = "";
    if (match.status === "matched") {
    statusBgColorClass =
    "bg-gradient-to-b from-emerald-400 to-emerald-600";
    } else if (match.status === "manual") {
    statusBgColorClass = "bg-gradient-to-b from-sky-400 to-sky-600";
    } else if (match.status === "skipped") {
    statusBgColorClass =
    "bg-gradient-to-b from-rose-400 to-rose-600";
    } else {
    statusBgColorClass =
    "bg-gradient-to-b from-slate-300 to-slate-500";
    }

    let glowClass = "";
    if (match.status === "matched") {
    glowClass =
    "hover:shadow-emerald-500/30 hover:ring-emerald-400/60";
    } else if (match.status === "manual") {
    glowClass = "hover:shadow-sky-500/30 hover:ring-sky-400/60";
    } else if (match.status === "skipped") {
    glowClass = "hover:shadow-rose-500/25 hover:ring-rose-400/60";
    } else {
    glowClass = "hover:shadow-slate-500/20 hover:ring-slate-300/60";
    }

    return (
    <MatchCard
    key={uniqueKey}
    match={match}
    uniqueKey={uniqueKey}
    borderColorClass={borderColorClass}
    statusBgColorClass={statusBgColorClass}
    glowClass={glowClass}
    formatStatusText={formatStatusText}
    handleOpenExternal={handleOpenExternal}
    handleKeyDown={handleKeyDown}
    isAdultContent={isAdultContent}
    shouldBlurImage={shouldBlurImage}
    toggleImageBlur={toggleImageBlur}
    onManualSearch={onManualSearch}
    onAcceptMatch={onAcceptMatch}
    onRejectMatch={onRejectMatch}
    onSelectAlternative={onSelectAlternative}
    onResetToPending={onResetToPending}
    />
    );
    })}
    </AnimatePresence>
    ) : (
    <div className="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-gray-700 dark:bg-gray-800">
    <p className="text-gray-600 dark:text-gray-400">
    {searchTerm
    ? `No manga matches found for "${searchTerm}" with the current filters.`
    : "No manga matches found with the current filters."}
    </p>
    </div>
    )}
    </div>

    {/* Pagination */}
    {totalPages > 1 && (
    <div
    className="mt-4 flex flex-col items-center justify-between space-y-3 sm:flex-row sm:space-y-0"
    aria-label="Pagination navigation"
    >
    <div className="text-sm text-gray-700 dark:text-gray-300">
    Showing{" "}
    <span className="font-medium">
    {(currentPage - 1) * itemsPerPage + 1}
    </span>{" "}
    to{" "}
    <span className="font-medium">
    {Math.min(currentPage * itemsPerPage, sortedMatches.length)}
    </span>{" "}
    of <span className="font-medium">{sortedMatches.length}</span>{" "}
    results
    <span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
    (Use ← → arrow keys to navigate, Home/End for first/last page)
    </span>
    </div>
    <div className="inline-flex items-center space-x-1">
    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(1)}
    disabled={currentPage === 1}
    aria-label="First page"
    title="Go to first page"
    >
    <span className="text-xs">«</span>
    <span className="sr-only sm:not-sr-only sm:ml-1">First</span>
    </button>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(currentPage - 1)}
    disabled={currentPage === 1}
    aria-label="Previous page"
    >
    <ChevronLeft className="h-4 w-4" aria-hidden="true" />
    <span className="sr-only sm:not-sr-only sm:ml-1">Previous</span>
    </button>

    <span className="mx-2 inline-flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
    <span className="font-medium">{currentPage}</span>
    <span className="mx-1">/</span>
    <span className="font-medium">{totalPages}</span>
    </span>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(currentPage + 1)}
    disabled={currentPage === totalPages}
    aria-label="Next page"
    >
    <span className="sr-only sm:not-sr-only sm:mr-1">Next</span>
    <ChevronRight className="h-4 w-4" aria-hidden="true" />
    </button>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(totalPages)}
    disabled={currentPage === totalPages}
    aria-label="Last page"
    title="Go to last page"
    >
    <span className="sr-only sm:not-sr-only sm:mr-1">Last</span>
    <span className="text-xs">»</span>
    </button>
    </div>
    </div>
    )}
    </div>
    );
    }