The props for the MangaMatchingPanel component.
The rendered manga matching panel React element.
export function MangaMatchingPanel({
matches,
onManualSearch,
onAcceptMatch,
onRejectMatch,
onSelectAlternative,
onResetToPending,
}: MangaMatchingPanelProps) {
const [currentPage, setCurrentPage] = useState(1);
const [statusFilters, setStatusFilters] = useState({
matched: true,
pending: true,
manual: true,
skipped: true,
});
const [searchTerm, setSearchTerm] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Add sort state
const [sortOption, setSortOption] = useState<{
field: "title" | "status" | "confidence" | "chapters_read";
direction: "asc" | "desc";
}>({ field: "title", direction: "asc" });
// Items per page
const itemsPerPage = 10;
// Add state for processing status of the skip button
const [isSkippingEmptyMatches, setIsSkippingEmptyMatches] = useState(false);
// Add state for processing status of the accept all button
const [isAcceptingAllMatches, setIsAcceptingAllMatches] = useState(false);
const [isReSearchingNoMatches, setIsReSearchingNoMatches] = useState(false);
// Add state for processing status of the reset skipped button
const [isResettingSkippedToPending, setIsResettingSkippedToPending] =
useState(false);
// Handler for opening external links in the default browser
const handleOpenExternal = (url: string) => (e: React.MouseEvent) => {
e.preventDefault();
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url);
} else {
// Fallback to regular link behavior if not in Electron
window.open(url, "_blank", "noopener,noreferrer");
}
};
// Helper function to create Kenmei URL from manga title
const createKenmeiUrl = (title: string) => {
if (!title) return null;
// 1. Convert to lowercase
// 2. Replace apostrophes with spaces
// 3. Replace special characters with spaces (except hyphens)
// 4. Replace multiple spaces with a single space
// 5. Replace spaces with hyphens
// 6. Trim hyphens from the beginning and end
const formattedTitle = title
.toLowerCase()
.replace(/'/g, " ")
.replace(/[^\w\s-]/g, " ") // Replace special chars with spaces instead of removing
.replace(/\s+/g, " ") // Normalize spaces
.trim() // Remove leading/trailing spaces
.replace(/\s/g, "-") // Replace spaces with hyphens
.replace(/^-+|-+$/g, ""); // Remove hyphens at start/end
return `https://www.kenmei.co/series/${formattedTitle}`;
};
// 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.charCodeAt(0), 0);
match = {
...match,
kenmeiManga: {
...match.kenmeiManga,
id: Math.abs(generatedId), // Use positive number
},
};
}
// Filter out Light Novels from anilistMatches
const filteredMatches = 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: filteredMatches,
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 !== undefined &&
match.selectedMatch.title.english !== null &&
match.selectedMatch.title.english
.toLowerCase()
.includes(searchTerm.toLowerCase())) ||
(match.selectedMatch?.title?.romaji !== undefined &&
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();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.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;
}
// Only handle left/right arrow keys
if (e.key === "ArrowLeft" && currentPage > 1) {
goToPage(currentPage - 1);
} else if (e.key === "ArrowRight" && currentPage < totalPages) {
goToPage(currentPage + 1);
}
};
// Add event listener
document.addEventListener("keydown", handleKeyDown);
// Cleanup
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [currentPage, totalPages]);
// Handle sort change
const handleSortChange = (
field: "title" | "status" | "confidence" | "chapters_read",
) => {
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: "title" | "status" | "confidence" | "chapters_read",
) => {
if (sortOption.field !== field) return null;
return (
<span className="ml-1 text-xs">
{sortOption.direction === "asc" ? "▲" : "▼"}
</span>
);
};
// Render confidence badge
const renderConfidenceBadge = (confidence: number | undefined) => {
// If confidence is undefined, null, or NaN, return null (don't render anything)
if (confidence === undefined || confidence === null || isNaN(confidence)) {
return null;
}
// Round the confidence value for display and comparison
const roundedConfidence = Math.min(99, Math.round(confidence)); // Cap at 99%
// Determine color scheme and label based on confidence level
let colorClass = "";
let barColorClass = "";
let label = "";
// Even if confidence is 0, we'll still show it with styling
if (roundedConfidence >= 90) {
colorClass =
"bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800";
barColorClass = "bg-green-500 dark:bg-green-400";
label = "High";
} else if (roundedConfidence >= 75) {
colorClass =
"bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800";
barColorClass = "bg-blue-500 dark:bg-blue-400";
label = "Good";
} else if (roundedConfidence >= 50) {
colorClass =
"bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800";
barColorClass = "bg-yellow-500 dark:bg-yellow-400";
label = "Medium";
} else {
colorClass =
"bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800";
barColorClass = "bg-red-500 dark:bg-red-400";
label = "Low";
}
return (
<div
className={`relative flex flex-col rounded-md border px-2.5 py-1 text-xs font-medium ${colorClass}`}
title={`${roundedConfidence}% confidence match`}
aria-label={`${label} confidence match: ${roundedConfidence}%`}
>
<div className="mb-1 flex items-center justify-between">
<span className="mr-1 font-semibold">{label}</span>
<span className="font-mono">{roundedConfidence}%</span>
</div>
{/* Progress bar background */}
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
{/* Progress bar indicator */}
<div
className={`h-full rounded-full ${barColorClass}`}
style={{ width: `${roundedConfidence}%` }}
></div>
</div>
</div>
);
};
// 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(" ");
};
// Render match status indicator
const renderStatusIndicator = (match: MangaMatchResult) => {
// Extract the data for the header badges
const headerIconData = [
{
value: match.kenmeiManga.chapters_read || 0,
icon: "chapters",
text: "chapters read",
},
{
value: match.kenmeiManga.score,
icon: "star",
text: "score",
hideIfZero: true,
},
].filter((data) => !data.hideIfZero || data.value > 0);
// Determine status color based on Kenmei status
let statusColorClass = "";
switch (match.kenmeiManga.status?.toLowerCase()) {
case "reading":
statusColorClass = "text-green-600 dark:text-green-400";
break;
case "completed":
statusColorClass = "text-blue-600 dark:text-blue-400";
break;
case "on_hold":
statusColorClass = "text-amber-600 dark:text-amber-400";
break;
case "dropped":
statusColorClass = "text-red-600 dark:text-red-400";
break;
case "plan_to_read":
statusColorClass = "text-purple-600 dark:text-purple-400";
break;
default:
statusColorClass = "text-gray-600 dark:text-gray-400";
break;
}
// Create Kenmei URL for the status indicator
const kenmeiUrl = createKenmeiUrl(match.kenmeiManga.title);
// Return the status indicator with correct color
return (
<div className="flex flex-col items-end">
<div className="text-muted-foreground line-clamp-1 text-xs">
<span className={statusColorClass}>
{formatStatusText(match.kenmeiManga.status)}
</span>
{headerIconData.map((data, i) => (
<React.Fragment key={`badge-${i}`}>
<span className="mx-1">•</span>
<span className={`inline-flex items-center`}>
<span>{data.value}</span>
<span className="ml-1">{data.text}</span>
</span>
</React.Fragment>
))}
</div>
{/* Kenmei link below status */}
{kenmeiUrl && (
<div className="mt-1 flex items-center">
<a
href={kenmeiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
aria-label="View on Kenmei (opens in external browser)"
onClick={handleOpenExternal(kenmeiUrl)}
>
<ExternalLink className="mr-1 h-3 w-3" aria-hidden="true" />
View on Kenmei
</a>
</div>
)}
</div>
);
};
// 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
if (onRejectMatch) {
// 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
if (onAcceptMatch) {
// 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
window.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
if (onResetToPending) {
// 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;
return (
<div
className="flex flex-col space-y-4"
ref={containerRef}
tabIndex={-1} // Make div focusable but not in tab order
>
{/* Stats and filter controls */}
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">
Match Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap gap-3">
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="bg-muted/50 text-foreground">
Total: {matchStats.total}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="bg-muted/80 text-foreground">
Pending: {matchStats.pending}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400"
>
Matched: {matchStats.matched}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"
>
Manual: {matchStats.manual}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
Skipped: {matchStats.skipped}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400"
>
No Matches: {noMatchesCount}
</Badge>
</div>
</div>
<div className="relative mb-6">
<div className="relative">
<Search className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4" />
<Input
ref={searchInputRef}
type="text"
className="pl-9"
placeholder="Search titles... (Ctrl+F)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
aria-label="Search manga titles"
/>
</div>
</div>
</CardContent>
</Card>
{/* Action buttons */}
<div className="mb-4 flex flex-col space-y-4">
{/* Skip Empty Matches button */}
{emptyMatchesCount > 0 && (
<Card className="p-4">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<Button
variant="outline"
onClick={handleSkipEmptyMatches}
disabled={isSkippingEmptyMatches}
className="bg-background w-full sm:w-auto"
>
{isSkippingEmptyMatches ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<X className="mr-2 h-4 w-4" />
Skip Empty Matches ({emptyMatchesCount})
</>
)}
</Button>
<span className="text-muted-foreground text-sm">
Mark all pending manga with no matches as skipped
</span>
</div>
</Card>
)}
{/* Re-Search Empty Matches button */}
{noMatchesCount > 0 && (
<Card className="p-4">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<Button
variant="outline"
onClick={handleReSearchNoMatches}
disabled={isReSearchingNoMatches}
className="w-full border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 hover:text-purple-800 sm:w-auto dark:border-purple-800 dark:bg-purple-900/20 dark:text-purple-300 dark:hover:bg-purple-900/30"
>
{isReSearchingNoMatches ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Re-search Empty Matches ({noMatchesCount})
</>
)}
</Button>
<span className="text-muted-foreground text-sm">
Attempt to find matches for all manga without results
</span>
</div>
</Card>
)}
{/* Reset Skipped to Pending button */}
{skippedMangaCount > 0 && (
<Card className="p-4">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<Button
variant="outline"
onClick={handleResetSkippedToPending}
disabled={isResettingSkippedToPending}
className="w-full border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800 sm:w-auto dark:border-orange-800 dark:bg-orange-900/20 dark:text-orange-300 dark:hover:bg-orange-900/30"
>
{isResettingSkippedToPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<ArrowLeft className="mr-2 h-4 w-4" />
Reset Skipped to Pending ({skippedMangaCount})
</>
)}
</Button>
<span className="text-muted-foreground text-sm">
Reset all skipped manga back to pending status
</span>
</div>
</Card>
)}
{/* Accept All Matches button */}
{pendingMatchesCount > 0 && (
<Card className="p-4">
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
<Button
variant="outline"
onClick={handleAcceptAllPendingMatches}
disabled={isAcceptingAllMatches}
className="w-full border-green-200 bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800 sm:w-auto dark:border-green-800 dark:bg-green-900/20 dark:text-green-300 dark:hover:bg-green-900/30"
>
{isAcceptingAllMatches ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Accept All Matches ({pendingMatchesCount})
</>
)}
</Button>
<div className="flex items-center gap-2">
<div className="group relative flex">
<Info className="text-muted-foreground h-4 w-4" />
<div className="bg-card absolute bottom-full left-1/2 mb-2 hidden w-64 -translate-x-1/2 transform rounded-md border px-3 py-2 text-xs font-medium shadow-lg group-hover:block">
It's still a good idea to skim over the matches to
ensure everything is correct before proceeding.
</div>
</div>
<span className="text-muted-foreground text-sm">
Accept all pending manga with available matches
</span>
</div>
</div>
</Card>
)}
</div>
{/* Filter selection */}
<Card className="mb-4">
<CardContent className="p-4">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Filter className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">Show status:</span>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center space-x-2">
<Checkbox
id="matched-filter"
checked={statusFilters.matched}
onCheckedChange={() =>
setStatusFilters({
...statusFilters,
matched: !statusFilters.matched,
})
}
/>
<label
htmlFor="matched-filter"
className="flex items-center text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Matched
<Badge
variant="outline"
className="ml-2 bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400"
>
{matchStats.matched}
</Badge>
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="pending-filter"
checked={statusFilters.pending}
onCheckedChange={() =>
setStatusFilters({
...statusFilters,
pending: !statusFilters.pending,
})
}
/>
<label
htmlFor="pending-filter"
className="flex items-center text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Pending
<Badge
variant="outline"
className="bg-muted/80 text-foreground ml-2"
>
{matchStats.pending}
</Badge>
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="manual-filter"
checked={statusFilters.manual}
onCheckedChange={() =>
setStatusFilters({
...statusFilters,
manual: !statusFilters.manual,
})
}
/>
<label
htmlFor="manual-filter"
className="flex items-center text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Manual
<Badge
variant="outline"
className="ml-2 bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"
>
{matchStats.manual}
</Badge>
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="skipped-filter"
checked={statusFilters.skipped}
onCheckedChange={() =>
setStatusFilters({
...statusFilters,
skipped: !statusFilters.skipped,
})
}
/>
<label
htmlFor="skipped-filter"
className="flex items-center text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Skipped
<Badge
variant="outline"
className="ml-2 bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400"
>
{matchStats.skipped}
</Badge>
</label>
</div>
<div className="ml-2 flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
setStatusFilters({
matched: true,
pending: true,
manual: true,
skipped: true,
})
}
className="h-7 px-2 text-xs"
>
Select All
</Button>
<Separator orientation="vertical" className="h-4" />
<Button
variant="ghost"
size="sm"
onClick={() =>
setStatusFilters({
matched: false,
pending: false,
manual: false,
skipped: false,
})
}
className="h-7 px-2 text-xs"
>
Clear All
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Sort options */}
<Card className="mb-4">
<CardContent className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-2 text-sm font-medium">Sort by:</span>
<Button
variant={sortOption.field === "title" ? "secondary" : "outline"}
size="sm"
onClick={() => handleSortChange("title")}
className="h-8"
>
Title{renderSortIndicator("title")}
</Button>
<Button
variant={sortOption.field === "status" ? "secondary" : "outline"}
size="sm"
onClick={() => handleSortChange("status")}
className="h-8"
>
Status{renderSortIndicator("status")}
</Button>
<Button
variant={
sortOption.field === "confidence" ? "secondary" : "outline"
}
size="sm"
onClick={() => handleSortChange("confidence")}
className="h-8"
>
Confidence{renderSortIndicator("confidence")}
</Button>
<Button
variant={
sortOption.field === "chapters_read" ? "secondary" : "outline"
}
size="sm"
onClick={() => handleSortChange("chapters_read")}
className="h-8"
>
Chapters Read{renderSortIndicator("chapters_read")}
</Button>
</div>
</CardContent>
</Card>
{/* Confidence accuracy notice */}
<Alert
variant="default"
className="mb-4 border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Confidence Percentages
</AlertTitle>
<AlertDescription>
<p className="mt-1 text-sm">
Please note that confidence match percentages are approximate and
may not always be accurate. It's recommended to review matches
manually, especially for manga with similar titles or multiple
adaptations.
</p>
<p className="mt-2 text-sm">
If you encounter cases where confidence scores are severely wrong or
misleading, please{" "}
<a
href="https://github.com/RLAlpha49/KenmeiToAnilist/issues"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline hover:text-amber-900 dark:hover:text-amber-100"
onClick={handleOpenExternal(
"https://github.com/RLAlpha49/KenmeiToAnilist/issues",
)}
>
open an issue on GitHub
</a>{" "}
with details about the title. This helps improve the confidence
system for future matching.
</p>
</AlertDescription>
</Alert>
{/* 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?.replace(/\s+/g, "_") || "unknown"}`;
return (
<motion.div
key={uniqueKey}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className={`rounded-lg border ${
match.status === "matched"
? "border-green-400 dark:border-green-600"
: match.status === "manual"
? "border-blue-400 dark:border-blue-600"
: match.status === "skipped"
? "border-red-400 dark:border-red-600"
: "border-gray-200 dark:border-gray-700"
} bg-white shadow-sm transition-all hover:shadow-md dark:bg-gray-800`}
tabIndex={0}
role="region"
aria-label={`Match result for ${match.kenmeiManga.title}`}
>
{/* Title and Status Bar with color indicator */}
<div
className={`relative overflow-hidden rounded-t-lg border-b border-gray-200 p-4 dark:border-gray-700`}
>
{/* Status color indicator */}
<div
className={`absolute top-0 bottom-0 left-0 w-1.5 ${
match.status === "matched"
? "bg-green-500 dark:bg-green-600"
: match.status === "manual"
? "bg-blue-500 dark:bg-blue-600"
: match.status === "skipped"
? "bg-red-500 dark:bg-red-600"
: "bg-gray-300 dark:bg-gray-600"
}`}
></div>
<div className="flex items-center justify-between pl-2">
<div className="flex items-center">
<Badge
variant="outline"
className={`mr-3 ${
match.status === "matched"
? "bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
: match.status === "manual"
? "bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
: match.status === "skipped"
? "bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{formatStatusText(match.status)}
</Badge>
<h3 className="line-clamp-1 text-lg font-medium text-gray-900 dark:text-white">
{match.kenmeiManga.title}
</h3>
</div>
<div className="ml-2 flex min-w-[250px] items-center justify-end">
{renderStatusIndicator(match)}
</div>
</div>
</div>
{/* Selected or best match */}
{(match.selectedMatch ||
(match.anilistMatches && match.anilistMatches.length > 0) ||
match.status === "skipped") && (
<div className="border-b border-gray-200 px-4 py-5 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="group relative flex-shrink-0">
{/* Cover image with proper fallbacks */}
{match.selectedMatch?.coverImage?.large ||
match.selectedMatch?.coverImage?.medium ||
match.anilistMatches?.[0]?.manga?.coverImage
?.large ||
match.anilistMatches?.[0]?.manga?.coverImage
?.medium ? (
<img
src={
match.selectedMatch?.coverImage?.large ||
match.selectedMatch?.coverImage?.medium ||
match.anilistMatches[0]?.manga?.coverImage
?.large ||
match.anilistMatches[0]?.manga?.coverImage
?.medium
}
alt={
match.selectedMatch?.title?.english ||
match.selectedMatch?.title?.romaji ||
match.anilistMatches?.[0]?.manga?.title
?.english ||
match.anilistMatches?.[0]?.manga?.title
?.romaji
}
className="h-44 w-32 rounded border border-gray-200 object-cover shadow-sm transition-all group-hover:scale-[1.02] group-hover:shadow dark:border-gray-700"
loading="lazy"
/>
) : (
<div className="flex h-44 w-32 items-center justify-center rounded border border-gray-200 bg-gray-100 shadow-sm dark:border-gray-700 dark:bg-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400">
No Image
</span>
</div>
)}
{/* Confidence badge overlay - only show for high confidence matches */}
{match.anilistMatches &&
match.anilistMatches.length > 0 &&
match.anilistMatches[0]?.confidence !==
undefined &&
match.anilistMatches[0]?.confidence >= 90 && (
<div className="absolute -top-2 -right-2 rounded-full bg-green-100 px-2 py-1 text-xs font-bold text-green-800 shadow-sm dark:bg-green-900 dark:text-green-200">
{Math.round(
match.anilistMatches[0].confidence,
)}
%
</div>
)}
</div>
<div className="flex flex-col space-y-1">
{match.status === "skipped" &&
!match.selectedMatch &&
!match.anilistMatches?.length ? (
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{match.kenmeiManga.title}
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-300">
Skipped
</span>
</h4>
) : (
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{match.selectedMatch?.title?.english ||
match.selectedMatch?.title?.romaji ||
match.anilistMatches?.[0]?.manga?.title
?.english ||
match.anilistMatches?.[0]?.manga?.title
?.romaji ||
"Unknown Title"}
</h4>
)}
{/* Show all available titles */}
<div className="flex flex-col text-sm text-gray-500 dark:text-gray-400">
{/* English title - if different from main display */}
{match.selectedMatch?.title?.english &&
match.selectedMatch?.title?.english !==
(match.selectedMatch?.title?.romaji ||
match.anilistMatches?.[0]?.manga?.title
?.english ||
match.anilistMatches?.[0]?.manga?.title
?.romaji) && (
<span>
English: {match.selectedMatch.title.english}
</span>
)}
{/* Romaji title - if different from main display */}
{match.selectedMatch?.title?.romaji &&
match.selectedMatch?.title?.romaji !==
match.selectedMatch?.title?.english && (
<span>
Romaji: {match.selectedMatch.title.romaji}
</span>
)}
{/* Native title */}
{match.selectedMatch?.title?.native && (
<span>
Native: {match.selectedMatch.title.native}
</span>
)}
{/* Synonyms */}
{match.selectedMatch?.synonyms &&
match.selectedMatch.synonyms.length > 0 && (
<span>
Synonyms:{" "}
{match.selectedMatch.synonyms.join(", ")}
</span>
)}
{/* Fallbacks to anilistMatches if selectedMatch is not available */}
{!match.selectedMatch &&
match.anilistMatches?.[0]?.manga?.title
?.english && (
<span>
English:{" "}
{
match.anilistMatches[0].manga.title
.english
}
</span>
)}
{!match.selectedMatch &&
match.anilistMatches?.[0]?.manga?.title
?.romaji && (
<span>
Romaji:{" "}
{match.anilistMatches[0].manga.title.romaji}
</span>
)}
{!match.selectedMatch &&
match.anilistMatches?.[0]?.manga?.title
?.native && (
<span>
Native:{" "}
{match.anilistMatches[0].manga.title.native}
</span>
)}
</div>
{/* Manga format and status info */}
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
{match.status !== "skipped" ||
match.selectedMatch ||
match.anilistMatches?.length ? (
<>
<Badge
variant="secondary"
className="font-normal"
>
{match.selectedMatch?.format ||
match.anilistMatches?.[0]?.manga
?.format ||
"Unknown Format"}
</Badge>
<Badge
variant="secondary"
className="font-normal"
>
{match.selectedMatch?.status ||
match.anilistMatches?.[0]?.manga
?.status ||
"Unknown Status"}
</Badge>
{((match.selectedMatch?.chapters &&
match.selectedMatch.chapters > 0) ||
(match.anilistMatches?.[0]?.manga
?.chapters &&
match.anilistMatches[0].manga.chapters >
0)) && (
<Badge
variant="secondary"
className="font-normal"
>
{match.selectedMatch?.chapters ||
match.anilistMatches?.[0]?.manga
?.chapters ||
0}{" "}
chapters
</Badge>
)}
</>
) : (
<span className="text-gray-500 italic">
No match information available
</span>
)}
</div>
{/* Kenmei status info */}
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
<Badge className="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300">
{formatStatusText(match.kenmeiManga.status)}
</Badge>
<Badge className="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300">
{match.kenmeiManga.chapters_read} chapters read
</Badge>
{match.kenmeiManga.score > 0 && (
<Badge className="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300">
Score: {match.kenmeiManga.score}/10
</Badge>
)}
</div>
</div>
</div>
<div className="flex flex-col items-end space-y-3">
{match.anilistMatches &&
match.anilistMatches.length > 0 &&
match.anilistMatches[0]?.confidence !== undefined &&
renderConfidenceBadge(
match.anilistMatches[0].confidence,
)}
<div className="flex space-x-2">
{(match.selectedMatch ||
(match.anilistMatches &&
match.anilistMatches.length > 0)) && (
<a
href={`https://anilist.co/manga/${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id || "unknown"}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
aria-label="View on AniList (opens in new tab)"
onClick={handleOpenExternal(
`https://anilist.co/manga/${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id || "unknown"}`,
)}
>
<ExternalLink
className="mr-1 h-3 w-3"
aria-hidden="true"
/>
AniList
</a>
)}
{(() => {
// Get the appropriate title for Kenmei link - for skipped items, always use Kenmei's title
const title =
match.kenmeiManga.title ||
match.selectedMatch?.title?.english ||
match.selectedMatch?.title?.romaji ||
match.anilistMatches?.[0]?.manga?.title
?.english ||
match.anilistMatches?.[0]?.manga?.title?.romaji;
const kenmeiUrl = createKenmeiUrl(title);
return kenmeiUrl ? (
<a
href={kenmeiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md bg-indigo-100 px-2.5 py-1 text-sm text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:hover:bg-indigo-800/30"
aria-label="View on Kenmei (opens in new tab)"
onClick={handleOpenExternal(kenmeiUrl)}
>
<ExternalLink
className="mr-1 h-3 w-3"
aria-hidden="true"
/>
Kenmei
<div className="group relative ml-1 inline-block">
<Info
className="h-3 w-3 text-indigo-500 dark:text-indigo-400"
aria-hidden="true"
/>
<div className="absolute right-0 bottom-full mb-2 hidden w-48 rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1.5 text-xs text-indigo-900 shadow-md group-hover:block dark:border-indigo-700 dark:bg-indigo-900 dark:text-indigo-100">
This link is dynamically generated and may
not work correctly.
</div>
</div>
</a>
) : null;
})()}
</div>
</div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex space-x-2 p-4">
{match.status === "pending" && (
<>
{match.anilistMatches &&
match.anilistMatches.length > 0 && (
<button
className="inline-flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none"
onClick={() => {
console.log(
`Clicked Accept Match for manga ID: ${match.kenmeiManga.id}, title: ${match.kenmeiManga.title}`,
);
if (onAcceptMatch) onAcceptMatch(match);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() => onAcceptMatch && onAcceptMatch(match),
)
}
aria-label={`Accept match for ${match.kenmeiManga.title}`}
>
<Check
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Accept Match
</button>
)}
<button
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
onClick={() => {
console.log(
`Clicked Search Manually for manga ID: ${match.kenmeiManga.id}, title: ${match.kenmeiManga.title}`,
);
if (onManualSearch)
onManualSearch(match.kenmeiManga);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() =>
onManualSearch &&
onManualSearch(match.kenmeiManga),
)
}
aria-label={`Search manually for ${match.kenmeiManga.title}`}
>
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
Search Manually
</button>
<button
className="inline-flex items-center rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onClick={() => {
if (onRejectMatch) onRejectMatch(match);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() => onRejectMatch && onRejectMatch(match),
)
}
aria-label={`Skip matching for ${match.kenmeiManga.title}`}
>
<X className="mr-2 h-4 w-4" aria-hidden="true" />
Skip
</button>
</>
)}
{match.status === "matched" && (
<>
<button
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
onClick={() => {
if (onManualSearch)
onManualSearch(match.kenmeiManga);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() =>
onManualSearch &&
onManualSearch(match.kenmeiManga),
)
}
aria-label={`Change match for ${match.kenmeiManga.title}`}
>
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
Change Match
</button>
<button
className="inline-flex items-center rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onClick={() => {
if (onResetToPending) onResetToPending(match);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() => onResetToPending && onResetToPending(match),
)
}
aria-label={`Reset ${match.kenmeiManga.title} to pending status`}
>
<ArrowLeft
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Reset to Pending
</button>
</>
)}
{match.status === "manual" && (
<>
<button
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
onClick={() => {
if (onManualSearch)
onManualSearch(match.kenmeiManga);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() =>
onManualSearch &&
onManualSearch(match.kenmeiManga),
)
}
aria-label={`Change match for ${match.kenmeiManga.title}`}
>
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
Change Match
</button>
<button
className="inline-flex items-center rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onClick={() => {
if (onResetToPending) onResetToPending(match);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() => onResetToPending && onResetToPending(match),
)
}
aria-label={`Reset ${match.kenmeiManga.title} to pending status`}
>
<ArrowLeft
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Reset to Pending
</button>
</>
)}
{match.status === "skipped" && (
<>
<button
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
onClick={() => {
if (onManualSearch)
onManualSearch(match.kenmeiManga);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() =>
onManualSearch &&
onManualSearch(match.kenmeiManga),
)
}
aria-label={`Find match for ${match.kenmeiManga.title}`}
>
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
Search Manually
</button>
<button
className="inline-flex items-center rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-none dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onClick={() => {
if (onResetToPending) onResetToPending(match);
}}
onKeyDown={(e) =>
handleKeyDown(
e,
() => onResetToPending && onResetToPending(match),
)
}
aria-label={`Reset ${match.kenmeiManga.title} to pending status`}
>
<ArrowLeft
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Reset to Pending
</button>
</>
)}
</div>
{/* Alternative matches - only show for non-matched entries */}
{match.anilistMatches &&
match.anilistMatches.length > 1 &&
match.status !== "matched" &&
match.status !== "manual" && (
<div className="rounded-b-lg border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<h4 className="mb-3 flex items-center text-sm font-medium text-gray-900 dark:text-white">
<ChevronRight
className="mr-1 h-4 w-4"
aria-hidden="true"
/>
Alternative Matches
</h4>
<div className="space-y-2">
{match.anilistMatches
.slice(1, 5)
.map((altMatch, index) => (
<div
key={
altMatch.manga?.id ||
altMatch.id ||
`alt-match-${index}`
}
className="flex items-center justify-between rounded-md border border-gray-200 bg-white p-2 transition-all hover:border-blue-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-600"
tabIndex={0}
role="button"
aria-label={`Select ${
altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji ||
"Alternative manga"
} as match`}
onKeyDown={(e) =>
handleKeyDown(e, () => {
if (onSelectAlternative)
onSelectAlternative(
match,
index + 1,
false,
true,
);
})
}
>
<div className="flex w-full flex-col space-y-2">
<div className="flex items-center space-x-3">
<div className="group relative flex-shrink-0">
{/* Cover image with better fallbacks */}
{altMatch.manga?.coverImage?.large ||
altMatch.manga?.coverImage?.medium ? (
<img
src={
altMatch.manga.coverImage.large ||
altMatch.manga.coverImage.medium
}
alt={
altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji ||
"Alternative manga"
}
className="h-32 w-20 rounded border border-gray-200 object-cover shadow-sm transition-all group-hover:scale-[1.02] group-hover:shadow dark:border-gray-700"
loading="lazy"
/>
) : (
<div className="flex h-32 w-20 items-center justify-center rounded border border-gray-200 bg-gray-100 shadow-sm dark:border-gray-700 dark:bg-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400">
No Image
</span>
</div>
)}
</div>
<div className="flex-1">
<p className="text-base font-medium text-gray-900 dark:text-white">
{altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji ||
"Unknown Manga"}
</p>
{/* Show all available titles */}
<div className="flex flex-col text-sm text-gray-500 dark:text-gray-400">
{/* English title - if different from main display */}
{altMatch.manga?.title?.english &&
altMatch.manga.title.english !==
altMatch.manga?.title?.romaji && (
<span>
English:{" "}
{altMatch.manga.title.english}
</span>
)}
{/* Romaji title - if different from main display */}
{altMatch.manga?.title?.romaji &&
altMatch.manga.title.romaji !==
altMatch.manga?.title?.english && (
<span>
Romaji:{" "}
{altMatch.manga.title.romaji}
</span>
)}
{/* Native title */}
{altMatch.manga?.title?.native && (
<span>
Native:{" "}
{altMatch.manga.title.native}
</span>
)}
{/* Synonyms */}
{altMatch.manga?.synonyms &&
altMatch.manga.synonyms.length >
0 && (
<span>
Synonyms:{" "}
{altMatch.manga.synonyms.join(
", ",
)}
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
{/* Format */}
<Badge
variant="outline"
className="text-xs font-normal"
>
{altMatch.manga?.format || "Unknown"}
</Badge>
{/* Status */}
<Badge
variant="outline"
className="text-xs font-normal"
>
{altMatch.manga?.status || "Unknown"}
</Badge>
{/* Chapters */}
{altMatch.manga?.chapters &&
Number(altMatch.manga.chapters) >
0 && (
<Badge
variant="outline"
className="text-xs font-normal"
>
{altMatch.manga.chapters} chapters
</Badge>
)}
</div>
</div>
<div className="ml-2 flex flex-col items-end space-y-3">
{altMatch.confidence !== undefined &&
renderConfidenceBadge(
altMatch.confidence,
)}
<div className="flex space-x-2">
<a
href={`https://anilist.co/manga/${altMatch.manga?.id || "unknown"}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
aria-label="View on AniList (opens in new tab)"
onClick={(e) => {
e.stopPropagation();
handleOpenExternal(
`https://anilist.co/manga/${altMatch.manga?.id || "unknown"}`,
)(e);
}}
>
<ExternalLink
className="mr-1 h-3 w-3"
aria-hidden="true"
/>
AniList
</a>
{(() => {
// Get the title for Kenmei link
const title =
altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji;
const kenmeiUrl =
createKenmeiUrl(title);
return kenmeiUrl ? (
<a
href={kenmeiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-xs text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:hover:bg-indigo-800/30"
aria-label="View on Kenmei (opens in new tab)"
onClick={(e) => {
e.stopPropagation();
handleOpenExternal(kenmeiUrl)(
e,
);
}}
>
<ExternalLink
className="mr-1 h-3 w-3"
aria-hidden="true"
/>
Kenmei
</a>
) : null;
})()}
</div>
</div>
</div>
<Separator className="bg-foreground/20 my-2" />
<div className="mt-2 flex justify-start">
<Button
variant="default"
size="sm"
className="bg-green-600 text-white hover:bg-green-700"
onClick={() => {
// Directly accept the alternative as the match without swapping
if (onSelectAlternative) {
onSelectAlternative(
match,
index + 1,
false,
true,
);
}
}}
aria-label={`Accept ${
altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji ||
"Unknown manga"
} as match (${altMatch.confidence !== undefined ? Math.round(altMatch.confidence) + "%" : "Unknown confidence"})`}
>
<Check
className="mr-1 h-3 w-3"
aria-hidden="true"
/>
Accept Match
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</motion.div>
);
})}
</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 pages)
</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(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>
</div>
</div>
)}
</div>
);
}
MangaMatchingPanel React component for reviewing, filtering, sorting, and managing manga match results, including manual search, acceptance, rejection, and alternative selection.