Component props.
Rendered matching panel with filtering, sorting, and management controls.
export function MangaMatchingPanel({
matches,
onManualSearch,
onAcceptMatch,
onRejectMatch,
onSelectAlternative,
onResetToPending,
searchQuery,
onSetMatchedToPending,
isSetMatchedToPendingDisabled,
isLoadingInitial = false,
selectedMatchIds,
onToggleSelection,
onSelectAll,
onClearSelection,
}: Readonly<MangaMatchingPanelProps>) {
const [currentPage, setCurrentPage] = useState(1);
const [statusFilters, setStatusFilters] = useState<StatusFiltersState>({
isMatchedVisible: true,
isPendingVisible: true,
isManualVisible: true,
isSkippedVisible: true,
});
const [searchTerm, setSearchTerm] = useState("");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedMatchFilters>(
defaultAdvancedFilters,
);
const [userPresets, setUserPresets] = useState<FilterPreset[]>([]);
const [isFuzzySearchEnabled, setIsFuzzySearchEnabled] = useState(true);
const searchInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastExternalSearchQuery = useRef<string | undefined>(undefined);
type SortField = "title" | "status" | "confidence" | "chaptersRead";
const [sortOption, setSortOption] = useState<{
field: SortField;
direction: "asc" | "desc";
}>({ field: "title", direction: "asc" });
const itemsPerPage = 10;
const [isSkippingEmptyMatches, setIsSkippingEmptyMatches] = useState(false);
const [isAcceptingAllMatches, setIsAcceptingAllMatches] = useState(false);
const [isReSearchingNoMatches, setIsReSearchingNoMatches] = useState(false);
const [isResettingSkippedToPending, setIsResettingSkippedToPending] =
useState(false);
const [isResettingMatchedToPending] = useState(false);
// Track which match IDs are currently being updated for visual feedback
const [updatingMatchIds, setUpdatingMatchIds] = useState<Set<number>>(
new Set(),
);
// Helper to check if a specific match is updating
const isMatchUpdating = useCallback(
(matchId: number): boolean => {
return updatingMatchIds.has(matchId);
},
[updatingMatchIds],
);
/**
* Clears the loading state for specified match IDs after a visual delay.
* @param ids - Array of match IDs to clear from updating state
*/
const clearLoadingStateAfterDelay = (ids: number[]): void => {
setTimeout(() => {
setUpdatingMatchIds((prev) => {
const newSet = new Set(prev);
for (const id of ids) {
newSet.delete(id);
}
return newSet;
});
}, 500);
};
/**
* Adds match IDs to the updating state set.
* @param ids - Array of match IDs to mark as updating
*/
const addToUpdatingSet = (ids: number[]): void => {
setUpdatingMatchIds((prev) => {
const newSet = new Set(prev);
for (const id of ids) {
newSet.add(id);
}
return newSet;
});
};
/**
* Wraps a handler callback to show/hide loading state for a specific match.
* Provides visual feedback during optimistic updates and background persistence.
*
* @param handler - Original callback handler
* @param getMatchId - Function to extract match ID from the parameter
* @returns Wrapped handler that tracks updating state
* @internal
*/
const wrapHandlerWithLoadingState = useCallback(
<T,>(
handler: ((arg: T) => void | Promise<void>) | undefined,
getMatchId: (arg: T) => number | number[],
): ((arg: T) => void | Promise<void>) | undefined => {
if (!handler) return undefined;
return (arg: T) => {
const matchIds = getMatchId(arg);
const ids = Array.isArray(matchIds) ? matchIds : [matchIds];
// Add to updating set
addToUpdatingSet(ids);
// Call the original handler
const result = handler(arg);
// If it's a promise, wait for it; otherwise, clear loading state after a brief delay
if (result instanceof Promise) {
result.finally(() => {
clearLoadingStateAfterDelay(ids);
});
} else {
clearLoadingStateAfterDelay(ids);
}
};
},
[],
);
// Wrapped handlers that provide loading state feedback
const wrappedOnAcceptMatch = wrapHandlerWithLoadingState(
onAcceptMatch,
(match) => {
if ("isBatchOperation" in match && match.isBatchOperation) {
return match.matches.map((m) => m.kenmeiManga.id);
}
return (match as MangaMatchResult).kenmeiManga.id;
},
);
const wrappedOnRejectMatch = wrapHandlerWithLoadingState(
onRejectMatch,
(match) => {
if ("isBatchOperation" in match && match.isBatchOperation) {
return match.matches.map((m) => m.kenmeiManga.id);
}
return (match as MangaMatchResult).kenmeiManga.id;
},
);
const wrappedOnResetToPending = wrapHandlerWithLoadingState(
onResetToPending,
(match) => {
if ("isBatchOperation" in match && match.isBatchOperation) {
return match.matches.map((m) => m.kenmeiManga.id);
}
return (match as MangaMatchResult).kenmeiManga.id;
},
);
// State for collapsible sections
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState(false);
// State for adult content settings and blur management
const [shouldBlurAdultContent, setShouldBlurAdultContent] = useState(true);
const [unblurredImages, setUnblurredImages] = useState<Set<string>>(
new Set(),
);
// State for Comick search setting (disabled - Comick temporarily unavailable)
// eslint-disable-next-line
const [isComickSearchEnabled, setIsComickSearchEnabled] = useState(false);
// State for MangaDex search setting
const [isMangaDexSearchEnabled, setIsMangaDexSearchEnabled] = useState(true);
// Load blur settings from match config
useEffect(() => {
const loadBlurSettings = async () => {
const matchConfig = getMatchConfig();
setShouldBlurAdultContent(matchConfig.blurAdultContent);
// Comick is temporarily disabled, keep it false
setIsComickSearchEnabled(false);
setIsMangaDexSearchEnabled(matchConfig.enableMangaDexSearch);
};
loadBlurSettings();
}, []);
// Load advanced filters from storage
useEffect(() => {
const loadAdvancedFilters = () => {
const savedFilters = getMatchFilters();
setAdvancedFilters(savedFilters);
};
loadAdvancedFilters();
}, []);
// Load user presets from storage
useEffect(() => {
const loadUserPresets = () => {
const presets = getFilterPresets();
setUserPresets(presets);
};
loadUserPresets();
}, []);
// Save advanced filters to storage (debounced)
useEffect(() => {
const timeoutId = setTimeout(() => {
saveMatchFilters(advancedFilters);
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
}, [advancedFilters]);
// Helper functions for adult content handling
const isAdultContent = (manga: AniListManga | undefined | null) => {
return manga?.isAdult === true;
};
const shouldBlurImage = (mangaId: string) => {
return shouldBlurAdultContent && !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) => {
setIsComickSearchEnabled(enabled);
try {
const currentConfig = getMatchConfig();
const updatedConfig = {
...currentConfig,
enableComickSearch: enabled,
};
saveMatchConfig(updatedConfig);
} catch (error) {
console.error(
"[MatchingPanel] Failed to save Comick search setting:",
error,
);
// Revert the state if saving failed
setIsComickSearchEnabled(!enabled);
}
};
// Handler for toggling MangaDex search setting
const handleMangaDexSearchToggle = async (enabled: boolean) => {
setIsMangaDexSearchEnabled(enabled);
try {
const currentConfig = getMatchConfig();
const updatedConfig = {
...currentConfig,
enableMangaDexSearch: enabled,
};
saveMatchConfig(updatedConfig);
} catch (error) {
console.error(
"[MatchingPanel] Failed to save MangaDex search setting:",
error,
);
// Revert the state if saving failed
setIsMangaDexSearchEnabled(!enabled);
}
};
// Selection helper
const isMatchSelected = useCallback(
(matchId: number): boolean => {
return selectedMatchIds?.has(matchId) ?? false;
},
[selectedMatchIds],
);
// Handler for opening external links in the default browser
const handleOpenExternal = (url: string) => async (e: React.MouseEvent) => {
e.preventDefault();
const result = await openExternalSafe(url);
if (!result.success) {
console.error(
"[MangaMatchingPanel] Failed to open external URL:",
result.error,
);
}
};
// Handler for advanced filter changes
const handleAdvancedFiltersChange = useCallback(
(newFilters: AdvancedMatchFilters) => {
setAdvancedFilters(newFilters);
// Reset to first page when filters change
setCurrentPage(1);
},
[],
);
// Handler for removing individual filters
const handleRemoveFilter = useCallback(
(
filterType: "confidence" | "format" | "genre" | "status" | "year" | "tag",
value?: string,
) => {
setAdvancedFilters((prev) => {
switch (filterType) {
case "confidence":
return { ...prev, confidence: { min: 0, max: 100 } };
case "format":
return {
...prev,
formats: prev.formats.filter((f) => f !== value),
};
case "genre":
return { ...prev, genres: prev.genres.filter((g) => g !== value) };
case "status":
return {
...prev,
publicationStatuses: prev.publicationStatuses.filter(
(s) => s !== value,
),
};
case "year":
return { ...prev, yearRange: { min: null, max: null } };
case "tag":
return {
...prev,
tags: (prev.tags || []).filter((t) => t !== value),
};
default:
return prev;
}
});
},
[],
);
// Handler for clearing all advanced filters
const handleClearAllFilters = useCallback(() => {
setAdvancedFilters(defaultAdvancedFilters);
setCurrentPage(1);
}, []);
// Handler for saving a preset
const handleSavePreset = useCallback(
(name: string, description?: string) => {
const newPreset = addFilterPreset({
name,
description,
filters: advancedFilters,
});
setUserPresets((prev) => [...prev, newPreset]);
console.log("[MangaMatchingPanel] Saved filter preset:", name);
},
[advancedFilters],
);
// Handler for applying a preset
const handleApplyPreset = useCallback((preset: FilterPreset) => {
setAdvancedFilters(preset.filters);
setCurrentPage(1);
console.log("[MangaMatchingPanel] Applied filter preset:", preset.name);
}, []);
// Handler for deleting a preset
const handleDeletePreset = useCallback((presetId: string) => {
const success = deleteFilterPreset(presetId);
if (success) {
setUserPresets((prev) => prev.filter((p) => p.id !== presetId));
console.log("[MangaMatchingPanel] Deleted filter preset:", presetId);
}
}, []);
// Process matches to filter out Light Novels from alternatives
const processedMatches = useMemo(() => {
return matches.map((match) => {
// Ensure manga has an ID - if missing, generate one based on title
if (match.kenmeiManga.id === undefined) {
// Create a simple hash from the title
const generatedId = match.kenmeiManga.title
.split("")
.reduce(
(hash, char) => (hash << 5) - hash + (char.codePointAt(0) ?? 0),
0,
);
match = {
...match,
kenmeiManga: {
...match.kenmeiManga,
id: Math.abs(generatedId),
},
};
}
// Filter out Light Novels from anilistMatches
const filteredAltMatches = match.anilistMatches
? match.anilistMatches.filter(
(m) =>
m.manga &&
m.manga.format !== "NOVEL" &&
m.manga.format !== "LIGHT_NOVEL",
)
: [];
// If the selected match is a Light Novel, clear it
const newSelectedMatch =
match.selectedMatch &&
(match.selectedMatch.format === "NOVEL" ||
match.selectedMatch.format === "LIGHT_NOVEL")
? undefined
: match.selectedMatch;
// Return a new object with filtered matches
return {
...match,
anilistMatches: filteredAltMatches,
selectedMatch: newSelectedMatch,
};
});
}, [matches]);
// Extract unique values for filter options
const availableGenres = useMemo(
() => extractUniqueGenres(processedMatches),
[processedMatches],
);
const availableFormats = useMemo(
() => extractUniqueFormats(processedMatches),
[processedMatches],
);
const availableStatuses = useMemo(
() => extractUniqueStatuses(processedMatches),
[processedMatches],
);
const availableTags = useMemo(
() => extractUniqueTags(processedMatches, advancedFilters.tags),
[processedMatches, advancedFilters.tags],
);
const yearRange = useMemo(
() => extractYearRange(processedMatches),
[processedMatches],
);
// Apply status filters first (fast, main thread)
const statusFilteredMatches = useMemo(() => {
return processedMatches.filter((match) => {
if (match.kenmeiManga.id === undefined) return false;
const statusMatch =
(match.status === "matched" && statusFilters.isMatchedVisible) ||
(match.status === "pending" && statusFilters.isPendingVisible) ||
(match.status === "manual" && statusFilters.isManualVisible) ||
(match.status === "skipped" && statusFilters.isSkippedVisible);
return statusMatch;
});
}, [processedMatches, statusFilters]);
// Apply advanced filters using worker (with debouncing)
const { filteredMatches: advancedFilteredMatches, isFiltering } =
useAdvancedFilter(
statusFilteredMatches,
advancedFilters,
300, // 300ms debounce
);
// Prepare search query for fuzzy search (extract text tokens only)
const fuzzySearchQuery = useMemo(() => {
const trimmedSearchTerm = searchTerm.trim();
if (!trimmedSearchTerm || !isFuzzySearchEnabled) {
return "";
}
// Check if search contains query syntax
const tokens = parseQuerySyntax(trimmedSearchTerm);
const hasFieldTokens = tokens.some((t) => t.type === "field");
const textTokens = tokens.filter((t) => t.type === "text");
if (hasFieldTokens && textTokens.length > 0) {
// If there are both field and text tokens, use only text tokens for fuzzy search
return textTokens.map((t) => t.value).join(" ");
} else if (!hasFieldTokens) {
// No field tokens, use the entire search term
return searchTerm;
}
// Only field tokens, no fuzzy search
return "";
}, [searchTerm, isFuzzySearchEnabled]);
// Use the async fuzzy search hook for large datasets
const { results: fuzzySearchedMatches } = useFuzzySearchResults(
fuzzySearchQuery,
advancedFilteredMatches,
{
debounceMs: 150,
enabled: isFuzzySearchEnabled && fuzzySearchQuery.length > 0,
},
);
// Apply filters in order: status ✓ → advanced ✓ → search (with field tokens)
const filteredMatches = useMemo(() => {
let filtered = fuzzySearchedMatches;
// Apply field-based filters from query syntax if present
if (searchTerm && isFuzzySearchEnabled) {
const tokens = parseQuerySyntax(searchTerm);
const hasFieldTokens = tokens.some((t) => t.type === "field");
if (hasFieldTokens) {
// Apply query syntax to filters (temporarily)
const queryFilters = applyQueryToFilters(tokens, advancedFilters);
filtered = filterByAdvancedCriteria(filtered, queryFilters);
}
} else if (searchTerm.trim() && !isFuzzySearchEnabled) {
const trimmedTerm = searchTerm.trim().toLowerCase();
// Fallback to substring search when fuzzy search is disabled
filtered = filtered.filter(
(match) =>
match.kenmeiManga.title.toLowerCase().includes(trimmedTerm) ||
match.selectedMatch?.title?.english
?.toLowerCase()
.includes(trimmedTerm) ||
match.selectedMatch?.title?.romaji
?.toLowerCase()
.includes(trimmedTerm),
);
}
return filtered;
}, [fuzzySearchedMatches, searchTerm, advancedFilters, isFuzzySearchEnabled]);
// 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 "chaptersRead":
chaptersA = a.kenmeiManga.chaptersRead || 0;
chaptersB = b.kenmeiManga.chaptersRead || 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, advancedFilters, searchTerm, totalPages, currentPage]);
// Focus search input when pressing Ctrl+F
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
searchInputRef.current?.focus();
}
};
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, []);
// Handle Ctrl+A to select all visible items on current page
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger when typing in input fields or text areas
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const modifier = isMac ? e.metaKey : e.ctrlKey;
// Ctrl/Cmd+A to select all visible items on current page
if (
modifier &&
e.key === "a" &&
currentMatches.length > 0 &&
onSelectAll
) {
e.preventDefault();
const visibleIds = currentMatches.map((match) => match.kenmeiManga.id);
onSelectAll(visibleIds);
}
};
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, [currentMatches, onSelectAll]);
// 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]);
// Confidence badge extracted to separate component
// Helper function to format status text nicely - moved outside for reuse
const formatStatusText = (status: string | undefined): string => {
if (!status) return "Unknown";
// Handle cases with underscores or spaces
return status
.split(/[_\s]+/) // Split by underscores or spaces
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
// Handle keyboard navigation for item selection
const handleKeyDown = (e: React.KeyboardEvent, callback: () => void) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
callback();
}
};
// Function to skip all pending matches with no results
const handleSkipEmptyMatches = () => {
// Set processing state to disable the button
setIsSkippingEmptyMatches(true);
// Find all pending manga with no matches
const pendingWithNoMatches = matches.filter(
(match) =>
match.status === "pending" &&
(!match.anilistMatches || match.anilistMatches.length === 0),
);
console.debug(
`[MatchingPanel] Skipping ${pendingWithNoMatches.length} pending manga with no matches`,
);
// Skip all matches at once if possible
if (pendingWithNoMatches.length > 0 && onRejectMatch) {
// Pass only the affected matches to the handler
onRejectMatch({ isBatchOperation: true, matches: pendingWithNoMatches });
// 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.debug(
`[MatchingPanel] Accepting ${pendingWithMatches.length} pending manga with matches`,
);
// Accept all matches at once if possible
if (pendingWithMatches.length > 0 && onAcceptMatch) {
// Pass only the affected matches to the handler
onAcceptMatch({ isBatchOperation: true, matches: pendingWithMatches });
// 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.debug(
`[MatchingPanel] Re-searching ${mangaWithoutMatches.length} manga without any matches`,
);
if (mangaWithoutMatches.length > 0) {
// Extract the Kenmei manga objects from the matches
const kenmeiMangaToResearch = mangaWithoutMatches.map(
(match) => match.kenmeiManga,
);
// Create a custom event to trigger the re-search process at the page level
// This allows us to use the same efficient batch processing as the "Fresh Search" button
const customEvent = new CustomEvent("reSearchEmptyMatches", {
detail: {
mangaToResearch: kenmeiMangaToResearch,
},
});
// Dispatch the event to be handled by the MatchingPage component
globalThis.dispatchEvent(customEvent);
// Reset processing state after a short delay
setTimeout(() => {
setIsReSearchingNoMatches(false);
}, 1000);
} else {
// Reset processing state if no matching items found
setIsReSearchingNoMatches(false);
}
};
// Get count of manga without any matches
const noMatchesCount = matches.filter(
(match) => !match.anilistMatches || match.anilistMatches.length === 0,
).length;
// Function to handle resetting all skipped manga to pending
const handleResetSkippedToPending = () => {
// Set processing state to disable the button
setIsResettingSkippedToPending(true);
// Find all skipped manga
const skippedManga = matches.filter((match) => match.status === "skipped");
console.debug(
`[MatchingPanel] Resetting ${skippedManga.length} skipped manga to pending status`,
);
// Reset all these manga to pending status
if (skippedManga.length > 0 && onResetToPending) {
// Pass only the affected matches to the handler
onResetToPending({ isBatchOperation: true, matches: skippedManga });
// Short delay to ensure state updates have time to process
setTimeout(() => {
setIsResettingSkippedToPending(false);
}, 500);
} else {
// Reset processing state if no matching items found
setIsResettingSkippedToPending(false);
}
};
// Get count of skipped manga
const skippedMangaCount = matches.filter(
(match) => match.status === "skipped",
).length;
// Count matched manga for bulk reset label
const matchedCount = matches.filter((m) => m.status === "matched").length;
// Batch selection descriptive text
let batchSelectionText = "Select multiple matches for batch operations";
if (selectedMatchIds && selectedMatchIds.size > 0) {
batchSelectionText = `${selectedMatchIds.size} match${selectedMatchIds.size === 1 ? "" : "es"} selected`;
}
return (
<div
className="flex flex-col space-y-4"
ref={containerRef}
tabIndex={-1} // Make div focusable but not in tab order
>
{/* PRIMARY CONTROLS - Always visible, grouped for proximity */}
<MatchStatisticsCard
matchStats={matchStats}
noMatchesCount={noMatchesCount}
searchTerm={searchTerm}
onSearchTermChange={(value) => setSearchTerm(value)}
searchInputRef={searchInputRef}
isFiltering={isFiltering}
/>
{/* PRIMARY CONTROLS - Filters, Sort, and Bulk Actions grouped in a centered 3x1 grid */}
<div className="flex w-full justify-center">
<div className="grid w-full max-w-7xl items-stretch gap-4 lg:grid-cols-3">
<div className="h-full w-full">
<MatchFilterControls
statusFilters={statusFilters}
setStatusFilters={setStatusFilters}
matchStats={matchStats}
/>
</div>
<div className="h-full w-full">
<MatchSortControls
sortOption={sortOption}
setSortOption={setSortOption}
/>
</div>
<div className="h-full w-full">
<MatchBulkActions
emptyMatchesCount={emptyMatchesCount}
onSkipEmptyMatches={handleSkipEmptyMatches}
isSkippingEmptyMatches={isSkippingEmptyMatches}
noMatchesCount={noMatchesCount}
onReSearchNoMatches={handleReSearchNoMatches}
isReSearchingNoMatches={isReSearchingNoMatches}
skippedMangaCount={skippedMangaCount}
onResetSkippedToPending={handleResetSkippedToPending}
isResettingSkippedToPending={isResettingSkippedToPending}
pendingMatchesCount={pendingMatchesCount}
onAcceptAllPendingMatches={handleAcceptAllPendingMatches}
isAcceptingAllMatches={isAcceptingAllMatches}
onSetMatchedToPending={onSetMatchedToPending}
isResettingMatchedToPending={isResettingMatchedToPending}
isSetMatchedToPendingDisabled={isSetMatchedToPendingDisabled}
matchedCount={matchedCount}
/>
</div>
</div>
</div>
{/* ADVANCED CONTROLS - Collapsible for progressive disclosure */}
<Collapsible
open={isAdvancedFiltersOpen}
onOpenChange={setIsAdvancedFiltersOpen}
>
<Card className="relative overflow-hidden rounded-3xl border border-white/40 bg-white/75 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/70">
<CardHeader className="relative z-10 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-violet-500/10 text-violet-500">
<ListFilter className="h-4 w-4" />
</div>
<div className="text-left">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900 dark:text-white">
Advanced Filters & Settings
<Badge
variant="secondary"
className="rounded-full bg-violet-500/10 px-2 py-0.5 text-xs text-violet-600 dark:text-violet-300"
>
Advanced
</Badge>
</CardTitle>
<p className="text-xs text-slate-500 dark:text-slate-400">
Fine-tune your results with additional criteria
</p>
</div>
</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<CollapsibleChevron isExpanded={isAdvancedFiltersOpen} />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="relative z-10 space-y-4 pb-5">
{/* Fuzzy Search Toggle */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3 dark:border-slate-700 dark:bg-slate-800/30">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-indigo-500" />
<label
htmlFor="fuzzy-search-toggle"
className="text-sm font-medium text-slate-700 dark:text-slate-300"
>
Fuzzy Search
</label>
</div>
<input
id="fuzzy-search-toggle"
type="checkbox"
checked={isFuzzySearchEnabled}
onChange={(e) => setIsFuzzySearchEnabled(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
title="Enable fuzzy matching for more flexible search results"
/>
</div>
{/* Advanced Filter Panel */}
<AdvancedFilterPanel
filters={advancedFilters}
onFiltersChange={handleAdvancedFiltersChange}
availableGenres={availableGenres}
availableFormats={availableFormats}
availableStatuses={availableStatuses}
availableTags={availableTags}
yearRange={yearRange}
matchCount={filteredMatches.length}
userPresets={userPresets}
onSavePreset={handleSavePreset}
onApplyPreset={handleApplyPreset}
onDeletePreset={handleDeletePreset}
/>
{/* Alternative Search Settings */}
<AlternativeSearchSettingsCard
isMangaDexSearchEnabled={isMangaDexSearchEnabled}
onComickSearchToggle={handleComickSearchToggle}
onMangaDexSearchToggle={handleMangaDexSearchToggle}
/>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Active Filter Chips - Show what's currently filtering */}
<FilterChips
filters={advancedFilters}
onRemoveFilter={handleRemoveFilter}
onClearAll={handleClearAllFilters}
/>
{/* Batch Selection Controls - Contextual, only when feature is available */}
{onSelectAll && (
<Card className="relative mb-4 overflow-hidden rounded-3xl border border-white/40 bg-white/75 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/70">
<CardHeader className="relative z-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold text-slate-900 dark:text-white">
Batch Selection
</CardTitle>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{batchSelectionText}
</p>
</div>
<div className="flex gap-2">
{selectedMatchIds && selectedMatchIds.size > 0 ? (
<button
type="button"
onClick={onClearSelection}
className="rounded-xl bg-slate-500/10 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-500/20 dark:text-slate-300 dark:hover:bg-slate-500/30"
title="Clear Selection (Esc)"
>
Clear Selection
</button>
) : (
<button
type="button"
onClick={() => {
if (onSelectAll) {
// Only select items currently visible on this page
const visibleIds = currentMatches.map(
(match) => match.kenmeiManga.id,
);
onSelectAll(visibleIds);
}
}}
className="rounded-xl bg-blue-500/10 px-4 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
title="Select All Visible Matches on This Page (Ctrl+A)"
>
Select All Visible
</button>
)}
</div>
</div>
</CardHeader>
</Card>
)}
{/* CONTENT AREA */}
{/* Confidence accuracy notice - moved closer to cards */}
<div className="relative mb-4 overflow-hidden rounded-3xl border border-amber-400/40 bg-amber-50/80 p-5 shadow-xl shadow-amber-500/10 backdrop-blur dark:border-amber-500/30 dark:bg-amber-900/25">
<div className="pointer-events-none absolute -top-20 left-10 h-48 w-48 rounded-full bg-amber-400/25 blur-3xl" />
<div className="pointer-events-none absolute -bottom-16 right-8 h-40 w-40 rounded-full bg-red-400/15 blur-3xl" />
<div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500/30 bg-amber-100/70 text-amber-600 shadow-inner dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-200">
<ShieldAlert className="h-5 w-5" />
</span>
<div className="space-y-2">
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100">
Confidence percentages are advisory
</h3>
<p className="text-sm text-amber-900/90 dark:text-amber-100/90">
Treat the score as a hint—not a guarantee. Always glance over
matches with similar titles, alternate editions, or multiple
adaptations.
</p>
<p className="text-sm text-amber-900/90 dark:text-amber-100/90">
Found a confidence outlier? Let me know so I can improve the
matching for everyone.
</p>
</div>
</div>
<div className="flex flex-col gap-3 md:items-end">
<Badge className="rounded-full border border-amber-500/20 bg-amber-200/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-100">
Manual review recommended
</Badge>
<a
href="https://github.com/RLAlpha49/KenmeiToAnilist/issues/new?template=confidence_mismatch.md"
target="_blank"
rel="noopener noreferrer"
onClick={handleOpenExternal(
"https://github.com/RLAlpha49/KenmeiToAnilist/issues/new?template=confidence_mismatch.md",
)}
className="inline-flex items-center gap-2 rounded-full border border-amber-500/40 bg-transparent px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-500/10 hover:text-amber-800 dark:border-amber-500/40 dark:text-amber-200 dark:hover:bg-amber-500/20"
>
<AlertTriangle className="h-4 w-4" />
Report a mismatch
</a>
</div>
</div>
</div>
{/* Match list */}
<div className="space-y-6" aria-live="polite">
{(() => {
let matchListContent: React.ReactNode;
if (isLoadingInitial && matches.length === 0) {
matchListContent = (
<motion.div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<motion.div
key={`skeleton-card-${index + 1}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
<SkeletonCard />
</motion.div>
))}
</motion.div>
);
} else if (currentMatches.length > 0) {
matchListContent = (
<AnimatePresence mode="popLayout">
{currentMatches.map((match, index) => {
// Generate a unique key using index as fallback when ID is undefined
const uniqueKey = match.kenmeiManga.id
? `${match.kenmeiManga.id}-${match.status}`
: `index-${index}-${match.status}-${match.kenmeiManga.title?.replaceAll(" ", "_") || "unknown"}`;
// Extract styles using the helper function
const { borderColorClass, statusBgColorClass, glowClass } =
getMatchCardStyles(match);
return (
<MatchCard
key={uniqueKey}
match={match}
uniqueKey={uniqueKey}
borderColorClass={borderColorClass}
statusBgColorClass={statusBgColorClass}
glowClass={glowClass}
formatStatusText={formatStatusText}
handleOpenExternal={handleOpenExternal}
handleKeyDown={handleKeyDown}
isAdultContent={isAdultContent}
shouldBlurImage={shouldBlurImage}
toggleImageBlur={toggleImageBlur}
onManualSearch={onManualSearch}
onAcceptMatch={wrappedOnAcceptMatch}
onRejectMatch={wrappedOnRejectMatch}
onSelectAlternative={onSelectAlternative}
onResetToPending={wrappedOnResetToPending}
isSelected={isMatchSelected(match.kenmeiManga.id)}
onToggleSelection={
onToggleSelection
? () => onToggleSelection(match.kenmeiManga.id)
: undefined
}
isUpdating={isMatchUpdating(match.kenmeiManga.id)}
/>
);
})}
</AnimatePresence>
);
} else {
matchListContent = (
<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>
);
}
return matchListContent;
})()}
</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>
);
}
Panel for reviewing, filtering, sorting, and managing manga match results. Supports manual search, acceptance, rejection, alternative selection, and batch operations.