Refactoring the RealTimeImportStats
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { ReactElement, useEffect, useState } from "react";
|
import { ReactElement, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
useGetImportStatisticsQuery,
|
useGetImportStatisticsQuery,
|
||||||
useGetWantedComicsQuery,
|
useGetWantedComicsQuery,
|
||||||
@@ -9,6 +8,11 @@ import {
|
|||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
import { useImportSocketEvents } from "../../hooks/useImportSocketEvents";
|
||||||
|
import { getComicDisplayLabel } from "../../shared/utils/formatting.utils";
|
||||||
|
import { AlertCard } from "../shared/AlertCard";
|
||||||
|
import { StatsCard } from "../shared/StatsCard";
|
||||||
|
import { ProgressBar } from "../shared/ProgressBar";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RealTimeImportStats component displays import statistics with a card-based layout and progress bar.
|
* RealTimeImportStats component displays import statistics with a card-based layout and progress bar.
|
||||||
@@ -21,48 +25,13 @@ import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
|||||||
* Additionally, it surfaces missing files detected by the file watcher, allowing users
|
* Additionally, it surfaces missing files detected by the file watcher, allowing users
|
||||||
* to see which previously-imported files are no longer found on disk.
|
* to see which previously-imported files are no longer found on disk.
|
||||||
*
|
*
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <RealTimeImportStats />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @returns {ReactElement} The rendered import statistics component
|
* @returns {ReactElement} The rendered import statistics component
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* The component subscribes to multiple socket events for real-time updates:
|
|
||||||
* - `LS_LIBRARY_STATS` / `LS_FILES_MISSING`: Triggers statistics refresh
|
|
||||||
* - `LS_FILE_DETECTED`: Shows toast notification for newly detected files
|
|
||||||
* - `LS_INCREMENTAL_IMPORT_STARTED`: Initializes progress tracking
|
|
||||||
* - `LS_COVER_EXTRACTED` / `LS_COVER_EXTRACTION_FAILED`: Updates progress counts
|
|
||||||
* - `LS_IMPORT_QUEUE_DRAINED`: Marks import as complete
|
|
||||||
*
|
|
||||||
* @see {@link useImportSessionStatus} for import session state management
|
|
||||||
* @see {@link useGetImportStatisticsQuery} for fetching import statistics
|
|
||||||
*/
|
*/
|
||||||
export const RealTimeImportStats = (): ReactElement => {
|
export const RealTimeImportStats = (): ReactElement => {
|
||||||
/** Current import error message to display, or null if no error */
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
/** Name of recently detected file for toast notification, auto-clears after 5 seconds */
|
const { socketImport, detectedFile } = useImportSocketEvents();
|
||||||
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
const importSession = useImportSessionStatus();
|
||||||
|
|
||||||
/**
|
|
||||||
* Real-time import progress state tracked via socket events.
|
|
||||||
* Separate from GraphQL query data to provide immediate UI updates.
|
|
||||||
*/
|
|
||||||
const [socketImport, setSocketImport] = useState<{
|
|
||||||
/** Whether import is currently in progress */
|
|
||||||
active: boolean;
|
|
||||||
/** Number of successfully completed import jobs */
|
|
||||||
completed: number;
|
|
||||||
/** Total number of jobs in the import queue */
|
|
||||||
total: number;
|
|
||||||
/** Number of failed import jobs */
|
|
||||||
failed: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@@ -78,8 +47,8 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stats = importStats?.getImportStatistics?.stats;
|
const stats = importStats?.getImportStatistics?.stats;
|
||||||
|
const missingCount = stats?.missingFiles ?? 0;
|
||||||
|
|
||||||
// File list for the detail panel — only fetched when there are missing files
|
|
||||||
const { data: missingComicsData } = useGetWantedComicsQuery(
|
const { data: missingComicsData } = useGetWantedComicsQuery(
|
||||||
{
|
{
|
||||||
paginationOptions: { limit: 3, page: 1 },
|
paginationOptions: { limit: 3, page: 1 },
|
||||||
@@ -88,26 +57,12 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
enabled: (stats?.missingFiles ?? 0) > 0,
|
enabled: missingCount > 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
||||||
|
|
||||||
const getMissingComicLabel = (comic: any): string => {
|
|
||||||
const series =
|
|
||||||
comic.canonicalMetadata?.series?.value ??
|
|
||||||
comic.inferredMetadata?.issue?.name;
|
|
||||||
const issueNum =
|
|
||||||
comic.canonicalMetadata?.issueNumber?.value ??
|
|
||||||
comic.inferredMetadata?.issue?.number;
|
|
||||||
if (series && issueNum) return `${series} #${issueNum}`;
|
|
||||||
if (series) return series;
|
|
||||||
return comic.rawFileDetails?.name ?? comic.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const importSession = useImportSessionStatus();
|
|
||||||
|
|
||||||
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
||||||
useStartIncrementalImportMutation({
|
useStartIncrementalImportMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -117,83 +72,10 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setImportError(
|
setImportError(error?.message || "Failed to start import. Please try again.");
|
||||||
error?.message || "Failed to start import. Please try again.",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasNewFiles = stats && stats.newFiles > 0;
|
|
||||||
const missingCount = stats?.missingFiles ?? 0;
|
|
||||||
|
|
||||||
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
|
||||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
|
||||||
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = getSocket("/");
|
|
||||||
|
|
||||||
const handleStatsChange = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileDetected = (payload: { filePath: string }) => {
|
|
||||||
handleStatsChange();
|
|
||||||
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
|
||||||
setDetectedFile(name);
|
|
||||||
setTimeout(() => setDetectedFile(null), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportStarted = () => {
|
|
||||||
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCoverExtracted = (payload: {
|
|
||||||
completedJobCount: number;
|
|
||||||
totalJobCount: number;
|
|
||||||
importResult: unknown;
|
|
||||||
}) => {
|
|
||||||
setSocketImport((prev) => ({
|
|
||||||
active: true,
|
|
||||||
completed: payload.completedJobCount,
|
|
||||||
total: payload.totalJobCount,
|
|
||||||
failed: prev?.failed ?? 0,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCoverExtractionFailed = (payload: {
|
|
||||||
failedJobCount: number;
|
|
||||||
importResult: unknown;
|
|
||||||
}) => {
|
|
||||||
setSocketImport((prev) =>
|
|
||||||
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueueDrained = () => {
|
|
||||||
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
|
||||||
handleStatsChange();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
|
||||||
socket.on("LS_FILES_MISSING", handleStatsChange);
|
|
||||||
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
|
||||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
|
||||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
|
||||||
socket.off("LS_FILES_MISSING", handleStatsChange);
|
|
||||||
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
|
||||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
|
||||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
||||||
};
|
|
||||||
}, [getSocket, queryClient]);
|
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
const handleStartImport = async () => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
@@ -226,78 +108,37 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
|
|
||||||
if (isStatsError || !stats) {
|
if (isStatsError || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
<AlertCard variant="error" title="Failed to Load Import Statistics">
|
||||||
<div className="flex items-start gap-3">
|
<p>Unable to retrieve import statistics from the server. Please check that the backend service is running.</p>
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
{isStatsError && (
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
<p className="mt-2">Error: {statsError instanceof Error ? statsError.message : "Unknown error"}</p>
|
||||||
</span>
|
)}
|
||||||
<div className="flex-1">
|
</AlertCard>
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
|
||||||
Failed to Load Import Statistics
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
Unable to retrieve import statistics from the server. Please check that the backend service is running.
|
|
||||||
</p>
|
|
||||||
{isStatsError && (
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-2">
|
|
||||||
Error: {statsError instanceof Error ? statsError.message : "Unknown error"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasNewFiles = stats.newFiles > 0;
|
||||||
const isFirstImport = stats.alreadyImported === 0;
|
const isFirstImport = stats.alreadyImported === 0;
|
||||||
const buttonText = isFirstImport
|
const buttonText = isFirstImport
|
||||||
? `Start Import (${stats.newFiles} files)`
|
? `Start Import (${stats.newFiles} files)`
|
||||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||||
|
|
||||||
// Determine what to show in each card based on current phase
|
|
||||||
const sessionStats = importSession.stats;
|
const sessionStats = importSession.stats;
|
||||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||||
|
|
||||||
const totalFiles = stats.totalLocalFiles;
|
|
||||||
const importedCount = stats.alreadyImported;
|
|
||||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||||
|
|
||||||
const showProgressBar = socketImport !== null;
|
const showProgressBar = socketImport !== null;
|
||||||
const socketProgressPct =
|
|
||||||
socketImport && socketImport.total > 0
|
|
||||||
? Math.round((socketImport.completed / socketImport.total) * 100)
|
|
||||||
: 0;
|
|
||||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||||
const showMissingCard = missingCount > 0;
|
const showMissingCard = missingCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Error Message */}
|
|
||||||
{importError && (
|
{importError && (
|
||||||
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
<AlertCard variant="error" title="Import Error" onDismiss={() => setImportError(null)}>
|
||||||
<div className="flex items-start gap-3">
|
{importError}
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
</AlertCard>
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
|
||||||
Import Error
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
{importError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setImportError(null)}
|
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
|
||||||
>
|
|
||||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File detected toast */}
|
|
||||||
{detectedFile && (
|
{detectedFile && (
|
||||||
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
||||||
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
||||||
@@ -307,7 +148,6 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Start Import button — only when idle with new files */}
|
|
||||||
{hasNewFiles && !importSession.isActive && (
|
{hasNewFiles && !importSession.isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStartImport}
|
onClick={handleStartImport}
|
||||||
@@ -319,121 +159,74 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress bar — shown while importing and once complete */}
|
|
||||||
{showProgressBar && (
|
{showProgressBar && (
|
||||||
<div className="space-y-1.5">
|
<ProgressBar
|
||||||
<div className="flex items-center justify-between text-sm">
|
current={socketImport!.completed}
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
total={socketImport!.total}
|
||||||
{socketImport!.active
|
isActive={socketImport!.active}
|
||||||
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
activeLabel={`Importing ${socketImport!.completed} / ${socketImport!.total}`}
|
||||||
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
completeLabel={`${socketImport!.completed} / ${socketImport!.total} imported`}
|
||||||
</span>
|
/>
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{socketProgressPct}% complete
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
|
||||||
style={{ width: `${socketProgressPct}%` }}
|
|
||||||
>
|
|
||||||
{socketImport!.active && (
|
|
||||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats cards */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{/* Total files */}
|
<StatsCard
|
||||||
<div
|
value={stats.totalLocalFiles}
|
||||||
className="rounded-lg p-6 text-center"
|
label="in import folder"
|
||||||
style={{ backgroundColor: "#6b7280" }}
|
backgroundColor="#6b7280"
|
||||||
>
|
/>
|
||||||
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
<StatsCard
|
||||||
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
value={stats.alreadyImported}
|
||||||
</div>
|
label={importSession.isActive ? "imported so far" : "imported in database"}
|
||||||
|
backgroundColor="#d8dab2"
|
||||||
{/* Imported */}
|
valueColor="text-gray-800"
|
||||||
<div
|
labelColor="text-gray-700"
|
||||||
className="rounded-lg p-6 text-center"
|
/>
|
||||||
style={{ backgroundColor: "#d8dab2" }}
|
|
||||||
>
|
|
||||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
|
||||||
{importedCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-700 font-medium">
|
|
||||||
{importSession.isActive ? "imported so far" : "imported in database"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Failed — only shown after a session with failures */}
|
|
||||||
{showFailedCard && (
|
{showFailedCard && (
|
||||||
<div className="rounded-lg p-6 text-center bg-red-500">
|
<StatsCard
|
||||||
<div className="text-4xl font-bold text-white mb-2">
|
value={failedCount}
|
||||||
{failedCount}
|
label="failed"
|
||||||
</div>
|
backgroundColor="bg-red-500"
|
||||||
<div className="text-sm text-red-100 font-medium">failed</div>
|
labelColor="text-red-100"
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Missing files — shown when watcher detects moved/deleted files */}
|
|
||||||
{showMissingCard && (
|
{showMissingCard && (
|
||||||
<div className="rounded-lg p-6 text-center bg-card-missing">
|
<StatsCard
|
||||||
<div className="text-4xl font-bold text-slate-700 mb-2">
|
value={missingCount}
|
||||||
{missingCount}
|
label="missing"
|
||||||
</div>
|
backgroundColor="bg-card-missing"
|
||||||
<div className="text-sm text-slate-800 font-medium">missing</div>
|
valueColor="text-slate-700"
|
||||||
</div>
|
labelColor="text-slate-800"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Missing files detail panel */}
|
|
||||||
{showMissingCard && (
|
{showMissingCard && (
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
|
<AlertCard variant="warning" title={`${missingCount} ${missingCount === 1 ? "file" : "files"} missing`}>
|
||||||
<div className="flex items-start gap-3">
|
<p>These files were previously imported but can no longer be found on disk. Move them back to restore access.</p>
|
||||||
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
|
{missingDocs.length > 0 && (
|
||||||
<div className="flex-1 min-w-0">
|
<ul className="mt-2 space-y-1">
|
||||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
{missingDocs.map((comic, i) => (
|
||||||
{missingCount} {missingCount === 1 ? "file" : "files"} missing
|
<li key={i} className="text-xs truncate">
|
||||||
</p>
|
{getComicDisplayLabel(comic)} is missing
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
</li>
|
||||||
These files were previously imported but can no longer be found
|
))}
|
||||||
on disk. Move them back to restore access.
|
{missingCount > 3 && (
|
||||||
</p>
|
<li className="text-xs text-amber-600 dark:text-amber-500">
|
||||||
{missingDocs.length > 0 && (
|
and {missingCount - 3} more.
|
||||||
<ul className="mt-2 space-y-1">
|
</li>
|
||||||
{missingDocs.map((comic, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="text-xs text-amber-700 dark:text-amber-400 truncate"
|
|
||||||
>
|
|
||||||
{getMissingComicLabel(comic)} is missing
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{missingCount > 3 && (
|
|
||||||
<li className="text-xs text-amber-600 dark:text-amber-500">
|
|
||||||
and {missingCount - 3} more.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
<Link
|
</ul>
|
||||||
to="/library?filter=missingFiles"
|
)}
|
||||||
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
<Link
|
||||||
>
|
to="/library?filter=missingFiles"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium underline underline-offset-2 hover:opacity-70"
|
||||||
<span className="underline">
|
>
|
||||||
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
|
<i className="icon-[solar--file-corrupted-outline] w-4 h-4" />
|
||||||
View Missing Files In Library
|
View Missing Files In Library
|
||||||
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
||||||
</span>
|
</Link>
|
||||||
</Link>
|
</AlertCard>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
108
src/client/components/shared/AlertCard.tsx
Normal file
108
src/client/components/shared/AlertCard.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type AlertVariant = "error" | "warning" | "info" | "success";
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
/** The visual style variant of the alert */
|
||||||
|
variant: AlertVariant;
|
||||||
|
/** Optional title displayed prominently */
|
||||||
|
title?: string;
|
||||||
|
/** Main message content */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional callback when dismiss button is clicked */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<AlertVariant, {
|
||||||
|
container: string;
|
||||||
|
border: string;
|
||||||
|
icon: string;
|
||||||
|
iconClass: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}> = {
|
||||||
|
error: {
|
||||||
|
container: "bg-red-50 dark:bg-red-900/20",
|
||||||
|
border: "border-red-500",
|
||||||
|
icon: "text-red-600 dark:text-red-400",
|
||||||
|
iconClass: "icon-[solar--danger-circle-bold]",
|
||||||
|
title: "text-red-800 dark:text-red-300",
|
||||||
|
text: "text-red-700 dark:text-red-400",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: "bg-amber-50 dark:bg-amber-900/20",
|
||||||
|
border: "border-amber-300",
|
||||||
|
icon: "text-amber-600 dark:text-amber-400",
|
||||||
|
iconClass: "icon-[solar--danger-triangle-bold]",
|
||||||
|
title: "text-amber-800 dark:text-amber-300",
|
||||||
|
text: "text-amber-700 dark:text-amber-400",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container: "bg-blue-50 dark:bg-blue-900/20",
|
||||||
|
border: "border-blue-500",
|
||||||
|
icon: "text-blue-600 dark:text-blue-400",
|
||||||
|
iconClass: "icon-[solar--info-circle-bold]",
|
||||||
|
title: "text-blue-800 dark:text-blue-300",
|
||||||
|
text: "text-blue-700 dark:text-blue-400",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
container: "bg-green-50 dark:bg-green-900/20",
|
||||||
|
border: "border-green-500",
|
||||||
|
icon: "text-green-600 dark:text-green-400",
|
||||||
|
iconClass: "icon-[solar--check-circle-bold]",
|
||||||
|
title: "text-green-800 dark:text-green-300",
|
||||||
|
text: "text-green-700 dark:text-green-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable alert card component for displaying messages with consistent styling.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AlertCard variant="error" title="Import Error" onDismiss={() => setError(null)}>
|
||||||
|
* {errorMessage}
|
||||||
|
* </AlertCard>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AlertCard({
|
||||||
|
variant,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onDismiss,
|
||||||
|
className = "",
|
||||||
|
}: AlertCardProps): ReactElement {
|
||||||
|
const styles = variantStyles[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border-l-4 ${styles.border} ${styles.container} p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`w-6 h-6 ${styles.icon} mt-0.5 shrink-0`}>
|
||||||
|
<i className={`h-6 w-6 ${styles.iconClass}`}></i>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{title && (
|
||||||
|
<p className={`font-semibold ${styles.title}`}>{title}</p>
|
||||||
|
)}
|
||||||
|
<div className={`text-sm ${styles.text} ${title ? "mt-1" : ""}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className={`${styles.icon} hover:opacity-70 transition-opacity`}
|
||||||
|
>
|
||||||
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertCard;
|
||||||
69
src/client/components/shared/ProgressBar.tsx
Normal file
69
src/client/components/shared/ProgressBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
/** Current progress value */
|
||||||
|
current: number;
|
||||||
|
/** Total/maximum value */
|
||||||
|
total: number;
|
||||||
|
/** Whether the progress is actively running (shows animation) */
|
||||||
|
isActive?: boolean;
|
||||||
|
/** Label shown on the left side */
|
||||||
|
activeLabel?: string;
|
||||||
|
/** Label shown when complete */
|
||||||
|
completeLabel?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable progress bar component with percentage display.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ProgressBar
|
||||||
|
* current={45}
|
||||||
|
* total={100}
|
||||||
|
* isActive={true}
|
||||||
|
* activeLabel="Importing 45 / 100"
|
||||||
|
* completeLabel="45 / 100 imported"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ProgressBar({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
isActive = false,
|
||||||
|
activeLabel,
|
||||||
|
completeLabel,
|
||||||
|
className = "",
|
||||||
|
}: ProgressBarProps): ReactElement {
|
||||||
|
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
const label = isActive ? activeLabel : completeLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-1.5 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
{label && (
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{percentage}% complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
52
src/client/components/shared/StatsCard.tsx
Normal file
52
src/client/components/shared/StatsCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
/** The main numeric value to display */
|
||||||
|
value: number;
|
||||||
|
/** Label text below the value */
|
||||||
|
label: string;
|
||||||
|
/** Background color (CSS color string or Tailwind class) */
|
||||||
|
backgroundColor?: string;
|
||||||
|
/** Text color for the value (defaults to white) */
|
||||||
|
valueColor?: string;
|
||||||
|
/** Text color for the label (defaults to slightly transparent) */
|
||||||
|
labelColor?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable stats card component for displaying numeric metrics.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <StatsCard
|
||||||
|
* value={42}
|
||||||
|
* label="imported in database"
|
||||||
|
* backgroundColor="#d8dab2"
|
||||||
|
* valueColor="text-gray-800"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function StatsCard({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
backgroundColor = "#6b7280",
|
||||||
|
valueColor = "text-white",
|
||||||
|
labelColor = "text-gray-200",
|
||||||
|
className = "",
|
||||||
|
}: StatsCardProps): ReactElement {
|
||||||
|
const isHexColor = backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-6 text-center ${!isHexColor ? backgroundColor : ""} ${className}`}
|
||||||
|
style={isHexColor ? { backgroundColor } : undefined}
|
||||||
|
>
|
||||||
|
<div className={`text-4xl font-bold ${valueColor} mb-2`}>{value}</div>
|
||||||
|
<div className={`text-sm ${labelColor} font-medium`}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatsCard;
|
||||||
122
src/client/hooks/useImportSocketEvents.ts
Normal file
122
src/client/hooks/useImportSocketEvents.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
|
export interface SocketImportState {
|
||||||
|
/** Whether import is currently in progress */
|
||||||
|
active: boolean;
|
||||||
|
/** Number of successfully completed import jobs */
|
||||||
|
completed: number;
|
||||||
|
/** Total number of jobs in the import queue */
|
||||||
|
total: number;
|
||||||
|
/** Number of failed import jobs */
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseImportSocketEventsReturn {
|
||||||
|
/** Real-time import progress state tracked via socket events */
|
||||||
|
socketImport: SocketImportState | null;
|
||||||
|
/** Name of recently detected file for toast notification */
|
||||||
|
detectedFile: string | null;
|
||||||
|
/** Clear the detected file notification */
|
||||||
|
clearDetectedFile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that manages socket event subscriptions for import-related events.
|
||||||
|
*
|
||||||
|
* Subscribes to:
|
||||||
|
* - `LS_LIBRARY_STATS` / `LS_FILES_MISSING`: Triggers statistics refresh
|
||||||
|
* - `LS_FILE_DETECTED`: Shows toast notification for newly detected files
|
||||||
|
* - `LS_INCREMENTAL_IMPORT_STARTED`: Initializes progress tracking
|
||||||
|
* - `LS_COVER_EXTRACTED` / `LS_COVER_EXTRACTION_FAILED`: Updates progress counts
|
||||||
|
* - `LS_IMPORT_QUEUE_DRAINED`: Marks import as complete
|
||||||
|
*/
|
||||||
|
export function useImportSocketEvents(): UseImportSocketEventsReturn {
|
||||||
|
const [socketImport, setSocketImport] = useState<SocketImportState | null>(null);
|
||||||
|
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { getSocket } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDetectedFile = useCallback(() => {
|
||||||
|
setDetectedFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
|
||||||
|
const handleStatsChange = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDetected = (payload: { filePath: string }) => {
|
||||||
|
handleStatsChange();
|
||||||
|
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
||||||
|
setDetectedFile(name);
|
||||||
|
setTimeout(() => setDetectedFile(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportStarted = () => {
|
||||||
|
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverExtracted = (payload: {
|
||||||
|
completedJobCount: number;
|
||||||
|
totalJobCount: number;
|
||||||
|
importResult: unknown;
|
||||||
|
}) => {
|
||||||
|
setSocketImport((prev) => ({
|
||||||
|
active: true,
|
||||||
|
completed: payload.completedJobCount,
|
||||||
|
total: payload.totalJobCount,
|
||||||
|
failed: prev?.failed ?? 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverExtractionFailed = (payload: {
|
||||||
|
failedJobCount: number;
|
||||||
|
importResult: unknown;
|
||||||
|
}) => {
|
||||||
|
setSocketImport((prev) =>
|
||||||
|
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQueueDrained = () => {
|
||||||
|
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
||||||
|
handleStatsChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
||||||
|
socket.on("LS_FILES_MISSING", handleStatsChange);
|
||||||
|
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
||||||
|
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||||
|
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||||
|
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||||
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
||||||
|
socket.off("LS_FILES_MISSING", handleStatsChange);
|
||||||
|
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
||||||
|
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||||
|
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||||
|
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||||
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
};
|
||||||
|
}, [getSocket, queryClient]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
socketImport,
|
||||||
|
detectedFile,
|
||||||
|
clearDetectedFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,3 +8,36 @@ export const removeLeadingPeriod = (input: string): string => {
|
|||||||
export const escapePoundSymbol = (input: string): string => {
|
export const escapePoundSymbol = (input: string): string => {
|
||||||
return input.replace(/\#/gm, "%23");
|
return input.replace(/\#/gm, "%23");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ComicBookDocument {
|
||||||
|
id: string;
|
||||||
|
canonicalMetadata?: {
|
||||||
|
series?: { value?: string | null } | null;
|
||||||
|
issueNumber?: { value?: string | number | null } | null;
|
||||||
|
} | null;
|
||||||
|
inferredMetadata?: {
|
||||||
|
issue?: { name?: string | null; number?: string | number | null } | null;
|
||||||
|
} | null;
|
||||||
|
rawFileDetails?: {
|
||||||
|
name?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a display label for a comic book from its metadata.
|
||||||
|
* Prioritizes canonical metadata, falls back to inferred, then raw file name.
|
||||||
|
*
|
||||||
|
* @param comic - The comic book document object
|
||||||
|
* @returns A formatted string like "Series Name #123" or the file name as fallback
|
||||||
|
*/
|
||||||
|
export const getComicDisplayLabel = (comic: ComicBookDocument): string => {
|
||||||
|
const series =
|
||||||
|
comic.canonicalMetadata?.series?.value ??
|
||||||
|
comic.inferredMetadata?.issue?.name;
|
||||||
|
const issueNum =
|
||||||
|
comic.canonicalMetadata?.issueNumber?.value ??
|
||||||
|
comic.inferredMetadata?.issue?.number;
|
||||||
|
if (series && issueNum) return `${series} #${issueNum}`;
|
||||||
|
if (series) return series;
|
||||||
|
return comic.rawFileDetails?.name ?? comic.id;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user