The props for the MangaMatchingPanel component.
The rendered manga matching panel React element.
export function MangaMatchingPanel({
matches,
onManualSearch,
onAcceptMatch,
onRejectMatch,
onSelectAlternative,
onResetToPending,
searchQuery,
}: Readonly<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);
const lastExternalSearchQuery = useRef<string | undefined>(undefined);
// Add sort state
type SortField = "title" | "status" | "confidence" | "chapters_read";
const [sortOption, setSortOption] = useState<{
field: SortField;
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);
// 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
const [enableComickSearch, setEnableComickSearch] = useState(true);
// Load blur settings from match config
useEffect(() => {
const loadBlurSettings = async () => {
const matchConfig = getMatchConfig();
setBlurAdultContent(matchConfig.blurAdultContent);
setEnableComickSearch(matchConfig.enableComickSearch);
};
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 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
?.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();
}
};
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;
}
// 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>
);
};
// 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) => (
<React.Fragment key={`badge-${data.icon}-${data.text}`}>
<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
// 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
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
// 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>
{/* Comick Search Settings */}
<Card className="mb-4">
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center space-x-2">
<Switch
id="comick-search-toggle"
checked={enableComickSearch}
onCheckedChange={handleComickSearchToggle}
/>
<Label
htmlFor="comick-search-toggle"
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable Comick Alternative Search
</Label>
</div>
<div className="group relative flex">
<Info className="text-muted-foreground h-4 w-4" />
<div className="bg-card absolute bottom-full left-1/2 z-50 mb-2 hidden w-80 -translate-x-1/2 transform rounded-md border px-3 py-2 text-xs font-medium shadow-lg group-hover:block">
When enabled, the system will attempt alternative searches
through Comick if the initial AniList search doesn't find
matches. This feature will be automatically ignored when rate
limited and will continue searching normally. Only the top
search result from Comick will be used.
</div>
</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"}`;
// Extract border color class for clarity
let borderColorClass = "";
if (match.status === "matched") {
borderColorClass = "border-green-400 dark:border-green-600";
} else if (match.status === "manual") {
borderColorClass = "border-blue-400 dark:border-blue-600";
} else if (match.status === "skipped") {
borderColorClass = "border-red-400 dark:border-red-600";
} else {
borderColorClass = "border-gray-200 dark:border-gray-700";
}
// Extract status color for the indicator
let statusBgColorClass = "";
if (match.status === "matched") {
statusBgColorClass = "bg-green-500 dark:bg-green-600";
} else if (match.status === "manual") {
statusBgColorClass = "bg-blue-500 dark:bg-blue-600";
} else if (match.status === "skipped") {
statusBgColorClass = "bg-red-500 dark:bg-red-600";
} else {
statusBgColorClass = "bg-gray-300 dark:bg-gray-600";
}
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 ${borderColorClass} bg-white shadow-sm transition-all hover:shadow-md dark:bg-gray-800`}
tabIndex={0}
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 ${statusBgColorClass}`}
></div>
<div className="flex items-center justify-between pl-2">
<div className="flex items-center">
{(() => {
let badgeClass = "";
if (match.status === "matched") {
badgeClass =
"bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400";
} else if (match.status === "manual") {
badgeClass =
"bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400";
} else if (match.status === "skipped") {
badgeClass =
"bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400";
} else {
badgeClass =
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
}
return (
<Badge
variant="outline"
className={`mr-3 ${badgeClass}`}
>
{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 and adult content handling */}
{match.selectedMatch?.coverImage?.large ||
match.selectedMatch?.coverImage?.medium ||
match.anilistMatches?.[0]?.manga?.coverImage
?.large ||
match.anilistMatches?.[0]?.manga?.coverImage
?.medium ? (
<div className="relative">
{isAdultContent(
match.selectedMatch ||
match.anilistMatches?.[0]?.manga,
) ? (
<button
type="button"
tabIndex={0}
aria-label={
shouldBlurImage(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
)
? "Reveal adult content cover image"
: "Hide adult content cover image"
}
onClick={() => {
toggleImageBlur(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleImageBlur(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
);
}
}}
className={`h-44 w-32 rounded border border-gray-200 bg-transparent object-cover p-0 shadow-sm transition-all group-hover:scale-[1.02] group-hover:shadow dark:border-gray-700 ${
shouldBlurImage(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
)
? "cursor-pointer blur-md"
: ""
}`}
style={{ padding: 0, border: "none" }}
>
<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 object-cover"
loading="lazy"
draggable={false}
/>
</button>
) : (
<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"
draggable={false}
/>
)}
{/* Adult content warning badge */}
{isAdultContent(
match.selectedMatch ||
match.anilistMatches?.[0]?.manga,
) && (
<div className="absolute top-1 left-1">
<Badge
variant="destructive"
className="bg-red-600 px-1 py-0 text-xs hover:bg-red-700"
title="Adult Content"
>
18+
</Badge>
</div>
)}
{/* Click to reveal hint for blurred images */}
{isAdultContent(
match.selectedMatch ||
match.anilistMatches?.[0]?.manga,
) &&
shouldBlurImage(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
) && (
<div className="absolute inset-0 flex items-center justify-center">
<Badge
variant="secondary"
className="cursor-pointer text-xs opacity-75"
onClick={() =>
toggleImageBlur(
`${match.selectedMatch?.id || match.anilistMatches?.[0]?.manga?.id}`,
)
}
>
Click to reveal
</Badge>
</div>
)}
</div>
) : (
<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>
)}
{/* Comick source badge - show when result came from Comick */}
{match.anilistMatches &&
match.anilistMatches.length > 0 &&
match.anilistMatches[0]?.comickSource && (
<div
className="absolute -top-2 -left-2 rounded-full bg-orange-100 px-2 py-1 text-xs font-bold text-orange-800 shadow-sm dark:bg-orange-900 dark:text-orange-200"
title={`Found via Comick: ${match.anilistMatches[0].comickSource.title}`}
>
Comick
</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>
{/* User's current list status (if on their list) */}
{(() => {
const mediaListEntry =
match.selectedMatch?.mediaListEntry ||
match.anilistMatches?.[0]?.manga
?.mediaListEntry;
if (
!mediaListEntry ||
!isOnUserList(mediaListEntry)
) {
return null;
}
return (
<div className="mt-2 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
On Your List:
</span>
<Badge
className={getStatusBadgeColor(
mediaListEntry.status,
)}
>
{formatMediaListStatus(
mediaListEntry.status,
)}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
<span className="text-blue-700 dark:text-blue-300">
Progress: {mediaListEntry.progress || 0}
{((match.selectedMatch?.chapters &&
match.selectedMatch.chapters > 0) ||
(match.anilistMatches?.[0]?.manga
?.chapters &&
match.anilistMatches[0].manga
.chapters > 0)) &&
` / ${match.selectedMatch?.chapters || match.anilistMatches?.[0]?.manga?.chapters}`}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
<span className="text-blue-700 dark:text-blue-300">
Score: {formatScore(mediaListEntry.score)}
</span>
</div>
</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 - prioritize AniList title for matched entries
const title =
match.selectedMatch?.title?.english ||
match.selectedMatch?.title?.romaji ||
match.anilistMatches?.[0]?.manga?.title
?.english ||
match.anilistMatches?.[0]?.manga?.title
?.romaji ||
match.kenmeiManga.title;
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">
{(() => {
const commonSearchButton = (
text: string,
ariaLabel: string,
) => (
<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 (match.status === "pending") {
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?.(match.kenmeiManga),
)
}
aria-label={ariaLabel}
>
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
{text}
</button>
);
const resetButton = (
<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?.(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>
);
switch (match.status) {
case "pending":
return (
<>
{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?.(match),
)
}
aria-label={`Accept match for ${match.kenmeiManga.title}`}
>
<Check
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Accept Match
</button>
)}
{commonSearchButton(
"Search Manually",
`Search manually for ${match.kenmeiManga.title}`,
)}
<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?.(match))
}
aria-label={`Skip matching for ${match.kenmeiManga.title}`}
>
<X
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Skip
</button>
</>
);
case "matched":
case "manual":
return (
<>
{commonSearchButton(
"Change Match",
`Change match for ${match.kenmeiManga.title}`,
)}
{resetButton}
</>
);
case "skipped":
return (
<>
{commonSearchButton(
"Search Manually",
`Find match for ${match.kenmeiManga.title}`,
)}
{resetButton}
</>
);
default:
return null;
}
})()}
</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"
aria-label={`Select ${
altMatch.manga?.title?.english ||
altMatch.manga?.title?.romaji ||
"Alternative manga"
} as match`}
>
<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 and adult content handling */}
{altMatch.manga?.coverImage?.large ||
altMatch.manga?.coverImage?.medium ? (
<div className="relative">
{isAdultContent(altMatch.manga) ? (
<button
type="button"
tabIndex={0}
aria-label={
shouldBlurImage(
`alt-${altMatch.manga?.id}`,
)
? "Reveal adult content cover image"
: "Hide adult content cover image"
}
onClick={(e) => {
e.stopPropagation();
toggleImageBlur(
`alt-${altMatch.manga?.id}`,
);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
e.stopPropagation();
toggleImageBlur(
`alt-${altMatch.manga?.id}`,
);
}
}}
className={`h-32 w-20 rounded border border-gray-200 bg-transparent object-cover p-0 shadow-sm transition-all group-hover:scale-[1.02] group-hover:shadow dark:border-gray-700 ${
shouldBlurImage(
`alt-${altMatch.manga?.id}`,
)
? "cursor-pointer blur-md"
: ""
}`}
style={{
padding: 0,
border: "none",
}}
>
<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 object-cover"
loading="lazy"
draggable={false}
/>
</button>
) : (
<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"
draggable={false}
/>
)}
{/* Adult content warning badge */}
{isAdultContent(altMatch.manga) && (
<div className="absolute top-1 left-1">
<Badge
variant="destructive"
className="bg-red-600 px-1 py-0 text-[10px] hover:bg-red-700"
title="Adult Content"
>
18+
</Badge>
</div>
)}
{/* Click to reveal hint for blurred images */}
{isAdultContent(altMatch.manga) &&
shouldBlurImage(
`alt-${altMatch.manga?.id}`,
) && (
<div className="absolute inset-0 flex items-center justify-center">
<Badge
variant="secondary"
className="cursor-pointer text-[10px] opacity-75"
onClick={(e) => {
e.stopPropagation();
toggleImageBlur(
`alt-${altMatch.manga?.id}`,
);
}}
>
Click
</Badge>
</div>
)}
</div>
) : (
<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>
{/* User's current list status for alternative (if on their list) */}
{(() => {
const mediaListEntry =
altMatch.manga?.mediaListEntry;
if (
!mediaListEntry ||
!isOnUserList(mediaListEntry)
) {
return null;
}
return (
<div className="mt-2 rounded-md bg-blue-50 p-2 dark:bg-blue-900/20">
<div className="flex items-center space-x-1">
<span className="text-xs font-medium text-blue-900 dark:text-blue-100">
On Your List:
</span>
<Badge
className={`${getStatusBadgeColor(mediaListEntry.status)} text-xs`}
>
{formatMediaListStatus(
mediaListEntry.status,
)}
</Badge>
</div>
<div className="mt-1 text-xs text-blue-700 dark:text-blue-300">
Progress:{" "}
{mediaListEntry.progress || 0}
{altMatch.manga?.chapters &&
altMatch.manga.chapters > 0 &&
` / ${altMatch.manga.chapters}`}
<span className="ml-2">
Score:{" "}
{formatScore(
mediaListEntry.score,
)}
</span>
</div>
<div className="mt-1 text-xs text-blue-700 dark:text-blue-300">
<span className="ml-2">
Score:{" "}
{formatScore(
mediaListEntry.score,
)}
</span>
</div>
</div>
);
})()}
</div>
<div className="ml-2 flex flex-col items-end space-y-3">
{altMatch.confidence !== undefined &&
renderConfidenceBadge(
altMatch.confidence,
)}
{/* Comick source badge for alternative matches */}
{altMatch.comickSource && (
<div
className="rounded-full bg-orange-100 px-2 py-1 text-xs font-bold text-orange-800 shadow-sm dark:bg-orange-900 dark:text-orange-200"
title={`Found via Comick: ${altMatch.comickSource.title}`}
>
Comick
</div>
)}
<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, 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>
);
}
MangaMatchingPanel React component for reviewing, filtering, sorting, and managing manga match results, including manual search, acceptance, rejection, and alternative selection.