The rendered background matching indicator or null if not applicable.
export function BackgroundMatchingIndicator() {
const location = useLocation();
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = useState(false);
const [matchingState, setMatchingState] = useState<{
isRunning: boolean;
current: number;
total: number;
currentTitle: string;
statusMessage: string;
estimatedRemainingSeconds?: number;
averageTimePerManga?: number;
} | null>(null);
const pathname = getPathname(location);
const isOnMatchingPage = pathname === "/review";
// Poll for matching state updates
useEffect(() => {
const updateState = () => {
if (globalThis.matchingProcessState?.isRunning) {
const state = globalThis.matchingProcessState;
setMatchingState({
isRunning: true,
current: state.progress.current,
total: state.progress.total,
currentTitle: state.progress.currentTitle,
statusMessage: state.statusMessage,
estimatedRemainingSeconds:
state.timeEstimate?.estimatedRemainingSeconds,
averageTimePerManga: state.timeEstimate?.averageTimePerManga,
});
} else {
setMatchingState(null);
}
};
// Initial update
updateState();
// Poll every second for updates
const interval = setInterval(updateState, 1000);
return () => clearInterval(interval);
}, []);
// Don't show if not matching or if on the matching page
if (!matchingState?.isRunning || isOnMatchingPage) {
return null;
}
const progressPercent =
matchingState.total > 0
? Math.round((matchingState.current / matchingState.total) * 100)
: 0;
const formatTime = (seconds: number | undefined): string => {
if (!seconds || seconds <= 0) return "calculating...";
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
};
return (
<AnimatePresence>
<motion.div
className="fixed top-20 right-4 z-50 w-80"
initial={{ opacity: 0, y: -20, x: 50 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: -20, x: 50 }}
transition={{ duration: 0.3 }}
>
<Card className="border-primary/20 bg-background/95 shadow-lg backdrop-blur-md">
{/* Header - Always visible */}
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Matching in Progress</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => navigate({ to: "/review" })}
title="Go to matching page"
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{/* Compact progress bar - Always visible */}
<div className="px-3 pb-3">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{matchingState.current} / {matchingState.total}
</span>
<span className="text-muted-foreground font-medium">
{progressPercent}%
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
{/* Expandable details */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden border-t"
>
<div className="space-y-2 p-3">
{/* Current manga being matched */}
{matchingState.currentTitle && (
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium">
Currently Matching:
</div>
<div className="text-foreground truncate text-sm">
{matchingState.currentTitle}
</div>
</div>
)}
{/* Status message */}
{matchingState.statusMessage && (
<div className="space-y-1">
<div className="text-muted-foreground text-xs font-medium">
Status:
</div>
<div className="text-muted-foreground text-xs">
{matchingState.statusMessage}
</div>
</div>
)}
{/* Time estimates */}
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="space-y-1">
<div className="text-muted-foreground text-xs">
Remaining:
</div>
<div className="text-foreground text-sm font-medium">
{formatTime(matchingState.estimatedRemainingSeconds)}
</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-xs">
Avg per manga:
</div>
<div className="text-foreground text-sm font-medium">
{matchingState.averageTimePerManga
? formatTime(matchingState.averageTimePerManga / 1000)
: "calculating..."}
</div>
</div>
</div>
{/* View button */}
<Button
variant="default"
size="sm"
className="mt-2 w-full"
onClick={() => navigate({ to: "/review" })}
>
<Search className="mr-2 h-3.5 w-3.5" />
View Matching Progress
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
</motion.div>
</AnimatePresence>
);
}
A floating indicator component that displays background matching progress when user is not on the matching page.