diff --git a/src/client/components/Import/RealTimeImportStats.tsx b/src/client/components/Import/RealTimeImportStats.tsx index 744bd28..dbdf3ad 100644 --- a/src/client/components/Import/RealTimeImportStats.tsx +++ b/src/client/components/Import/RealTimeImportStats.tsx @@ -1,6 +1,5 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useState } from "react"; import { Link } from "react-router-dom"; -import { useQueryClient } from "@tanstack/react-query"; import { useGetImportStatisticsQuery, useGetWantedComicsQuery, @@ -9,6 +8,11 @@ import { import { useStore } from "../../store"; import { useShallow } from "zustand/react/shallow"; 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. @@ -21,48 +25,13 @@ import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; * Additionally, it surfaces missing files detected by the file watcher, allowing users * to see which previously-imported files are no longer found on disk. * - * @component - * @example - * ```tsx - * - * ``` - * * @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 => { - /** Current import error message to display, or null if no error */ const [importError, setImportError] = useState(null); - /** Name of recently detected file for toast notification, auto-clears after 5 seconds */ - const [detectedFile, setDetectedFile] = useState(null); - - /** - * 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 { socketImport, detectedFile } = useImportSocketEvents(); + const importSession = useImportSessionStatus(); const { getSocket, disconnectSocket, importJobQueue } = useStore( useShallow((state) => ({ @@ -78,8 +47,8 @@ export const RealTimeImportStats = (): ReactElement => { ); 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( { paginationOptions: { limit: 3, page: 1 }, @@ -88,26 +57,12 @@ export const RealTimeImportStats = (): ReactElement => { { refetchOnWindowFocus: false, refetchInterval: false, - enabled: (stats?.missingFiles ?? 0) > 0, + enabled: missingCount > 0, }, ); 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 } = useStartIncrementalImportMutation({ onSuccess: (data) => { @@ -117,83 +72,10 @@ export const RealTimeImportStats = (): ReactElement => { } }, onError: (error: any) => { - setImportError( - error?.message || "Failed to start import. Please try again.", - ); + setImportError(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 () => { setImportError(null); @@ -226,78 +108,37 @@ export const RealTimeImportStats = (): ReactElement => { if (isStatsError || !stats) { return ( -
-
- - - -
-

- Failed to Load Import Statistics -

-

- Unable to retrieve import statistics from the server. Please check that the backend service is running. -

- {isStatsError && ( -

- Error: {statsError instanceof Error ? statsError.message : "Unknown error"} -

- )} -
-
-
+ +

Unable to retrieve import statistics from the server. Please check that the backend service is running.

+ {isStatsError && ( +

Error: {statsError instanceof Error ? statsError.message : "Unknown error"}

+ )} +
); } + const hasNewFiles = stats.newFiles > 0; const isFirstImport = stats.alreadyImported === 0; const buttonText = isFirstImport ? `Start Import (${stats.newFiles} files)` : `Start Incremental Import (${stats.newFiles} new files)`; - // Determine what to show in each card based on current phase const sessionStats = importSession.stats; const hasSessionStats = importSession.isActive && sessionStats !== null; - - const totalFiles = stats.totalLocalFiles; - const importedCount = stats.alreadyImported; const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0; const showProgressBar = socketImport !== null; - const socketProgressPct = - socketImport && socketImport.total > 0 - ? Math.round((socketImport.completed / socketImport.total) * 100) - : 0; const showFailedCard = hasSessionStats && failedCount > 0; const showMissingCard = missingCount > 0; return (
- {/* Error Message */} {importError && ( -
-
- - - -
-

- Import Error -

-

- {importError} -

-
- -
-
+ setImportError(null)}> + {importError} + )} - {/* File detected toast */} {detectedFile && (
@@ -307,7 +148,6 @@ export const RealTimeImportStats = (): ReactElement => {
)} - {/* Start Import button — only when idle with new files */} {hasNewFiles && !importSession.isActive && ( )} - {/* Progress bar — shown while importing and once complete */} {showProgressBar && ( -
-
- - {socketImport!.active - ? `Importing ${socketImport!.completed} / ${socketImport!.total}` - : `${socketImport!.completed} / ${socketImport!.total} imported`} - - - {socketProgressPct}% complete - -
-
-
- {socketImport!.active && ( -
- )} -
-
-
+ )} - {/* Stats cards */}
- {/* Total files */} -
-
{totalFiles}
-
in import folder
-
- - {/* Imported */} -
-
- {importedCount} -
-
- {importSession.isActive ? "imported so far" : "imported in database"} -
-
- - {/* Failed — only shown after a session with failures */} + + {showFailedCard && ( -
-
- {failedCount} -
-
failed
-
+ )} - - {/* Missing files — shown when watcher detects moved/deleted files */} {showMissingCard && ( -
-
- {missingCount} -
-
missing
-
+ )}
- {/* Missing files detail panel */} {showMissingCard && ( -
-
- -
-

- {missingCount} {missingCount === 1 ? "file" : "files"} missing -

-

- These files were previously imported but can no longer be found - on disk. Move them back to restore access. -

- {missingDocs.length > 0 && ( -
    - {missingDocs.map((comic, i) => ( -
  • - {getMissingComicLabel(comic)} is missing -
  • - ))} - {missingCount > 3 && ( -
  • - and {missingCount - 3} more. -
  • - )} -
+ +

These files were previously imported but can no longer be found on disk. Move them back to restore access.

+ {missingDocs.length > 0 && ( +
    + {missingDocs.map((comic, i) => ( +
  • + {getComicDisplayLabel(comic)} is missing +
  • + ))} + {missingCount > 3 && ( +
  • + and {missingCount - 3} more. +
  • )} - - - - - View Missing Files In Library - - - -
-
-
+ + )} + + + View Missing Files In Library + + + )}
); diff --git a/src/client/components/shared/AlertCard.tsx b/src/client/components/shared/AlertCard.tsx new file mode 100644 index 0000000..34d9e53 --- /dev/null +++ b/src/client/components/shared/AlertCard.tsx @@ -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 = { + 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 + * setError(null)}> + * {errorMessage} + * + * ``` + */ +export function AlertCard({ + variant, + title, + children, + onDismiss, + className = "", +}: AlertCardProps): ReactElement { + const styles = variantStyles[variant]; + + return ( +
+
+ + + +
+ {title && ( +

{title}

+ )} +
+ {children} +
+
+ {onDismiss && ( + + )} +
+
+ ); +} + +export default AlertCard; diff --git a/src/client/components/shared/ProgressBar.tsx b/src/client/components/shared/ProgressBar.tsx new file mode 100644 index 0000000..e7c5884 --- /dev/null +++ b/src/client/components/shared/ProgressBar.tsx @@ -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 + * + * ``` + */ +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 ( +
+
+ {label && ( + + {label} + + )} + + {percentage}% complete + +
+
+
+ {isActive && ( +
+ )} +
+
+
+ ); +} + +export default ProgressBar; diff --git a/src/client/components/shared/StatsCard.tsx b/src/client/components/shared/StatsCard.tsx new file mode 100644 index 0000000..45a7309 --- /dev/null +++ b/src/client/components/shared/StatsCard.tsx @@ -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 + * + * ``` + */ +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 ( +
+
{value}
+
{label}
+
+ ); +} + +export default StatsCard; diff --git a/src/client/hooks/useImportSocketEvents.ts b/src/client/hooks/useImportSocketEvents.ts new file mode 100644 index 0000000..985bd63 --- /dev/null +++ b/src/client/hooks/useImportSocketEvents.ts @@ -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(null); + const [detectedFile, setDetectedFile] = useState(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, + }; +} diff --git a/src/client/shared/utils/formatting.utils.ts b/src/client/shared/utils/formatting.utils.ts index 273ead6..3bbf167 100644 --- a/src/client/shared/utils/formatting.utils.ts +++ b/src/client/shared/utils/formatting.utils.ts @@ -8,3 +8,36 @@ export const removeLeadingPeriod = (input: string): string => { export const escapePoundSymbol = (input: string): string => { 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; +};