diff --git a/.claude/skills/typescript/SKILL.md b/.claude/skills/typescript/SKILL.md index 1379556..452ee3d 100644 --- a/.claude/skills/typescript/SKILL.md +++ b/.claude/skills/typescript/SKILL.md @@ -36,24 +36,31 @@ const b = 2; // const a = 1, b = 2; // WRONG Types and Interfaces -Prefer Interfaces Over Type Aliases +Prefer Type Aliases Over Interfaces -// Good: interface for object shapes -interface User { - id: string; - name: string; - email?: string; -} - -// Avoid: type alias for object shapes +// Good: type alias for object shapes type User = { id: string; name: string; + email?: string; }; -// Type aliases OK for unions, intersections, mapped types +// Avoid: interface for object shapes +// interface User { +// id: string; +// name: string; +// } + +// Type aliases work for everything: objects, unions, intersections, mapped types type Status = 'active' | 'inactive'; type Combined = TypeA & TypeB; +type Handler = (event: Event) => void; + +// Benefits of types over interfaces: +// 1. Consistent syntax for all type definitions +// 2. Cannot be merged/extended unexpectedly (no declaration merging) +// 3. Better for union types and computed properties +// 4. Works with utility types more naturally Type Inference diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx index 79d7aec..41e5d9e 100644 --- a/src/client/components/Import/Import.tsx +++ b/src/client/components/Import/Import.tsx @@ -6,7 +6,6 @@ */ import { ReactElement, useEffect, useRef, useState } from "react"; -import { format } from "date-fns"; import { isEmpty } from "lodash"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useStore } from "../../store"; @@ -14,30 +13,11 @@ import { useShallow } from "zustand/react/shallow"; import axios from "axios"; import { useGetJobResultStatisticsQuery } from "../../graphql/generated"; import { RealTimeImportStats } from "./RealTimeImportStats"; +import { PastImportsTable } from "./PastImportsTable"; +import { AlertBanner } from "../shared/AlertBanner"; import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; import { SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints"; - -/** - * Represents an issue with a configured directory. - * @interface DirectoryIssue - * @property {string} directory - Path to the directory with issues - * @property {string} issue - Description of the issue - */ -interface DirectoryIssue { - directory: string; - issue: string; -} - -/** - * Result of directory status check from the backend. - * @interface DirectoryStatus - * @property {boolean} isValid - Whether all required directories are accessible - * @property {DirectoryIssue[]} issues - List of specific issues found - */ -interface DirectoryStatus { - isValid: boolean; - issues: DirectoryIssue[]; -} +import type { DirectoryStatus, DirectoryIssue } from "./import.types"; /** * Import page component for managing comic library imports. @@ -59,28 +39,36 @@ export const Import = (): ReactElement => { importJobQueue: state.importJobQueue, getSocket: state.getSocket, disconnectSocket: state.disconnectSocket, - })), + })) ); // Check if required directories exist - const { data: directoryStatus, isLoading: isCheckingDirectories, isError: isDirectoryCheckError, error: directoryError } = useQuery({ + const { + data: directoryStatus, + isLoading: isCheckingDirectories, + isError: isDirectoryCheckError, + error: directoryError, + } = useQuery({ queryKey: ["directoryStatus"], queryFn: async (): Promise => { - const response = await axios.get(`${SETTINGS_SERVICE_BASE_URI}/getDirectoryStatus`); + const response = await axios.get( + `${SETTINGS_SERVICE_BASE_URI}/getDirectoryStatus` + ); return response.data; }, refetchOnWindowFocus: false, - staleTime: 30000, // Cache for 30 seconds - retry: false, // Don't retry on failure - show error immediately + staleTime: 30000, + retry: false, }); // Use isValid for quick check, issues array for detailed display - // If there's an error fetching directory status, assume directories are invalid const directoryCheckFailed = isDirectoryCheckError; - const hasAllDirectories = directoryCheckFailed ? false : (directoryStatus?.isValid ?? true); + const hasAllDirectories = directoryCheckFailed + ? false + : (directoryStatus?.isValid ?? true); const directoryIssues = directoryStatus?.issues ?? []; - // Force re-import mutation - re-imports all files regardless of import status + // Force re-import mutation const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({ mutationFn: async () => { const sessionId = localStorage.getItem("sessionId") || ""; @@ -97,7 +85,11 @@ export const Import = (): ReactElement => { }, onError: (error: any) => { console.error("Failed to start force re-import:", error); - setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again."); + setImportError( + error?.response?.data?.message || + error?.message || + "Failed to start force re-import. Please try again." + ); }, }); @@ -107,14 +99,11 @@ export const Import = (): ReactElement => { const hasActiveSession = importSession.isActive; const wasComplete = useRef(false); - // React to importSession.isComplete rather than socket events — more reliable - // since it's derived from the actual GraphQL state, not a raw socket event. + // React to importSession.isComplete for state updates useEffect(() => { if (importSession.isComplete && !wasComplete.current) { wasComplete.current = true; - // Small delay so the backend has time to commit job result stats setTimeout(() => { - // Invalidate the cache to force a fresh fetch of job result statistics queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); refetch(); }, 1500); @@ -124,21 +113,23 @@ export const Import = (): ReactElement => { } }, [importSession.isComplete, refetch, importJobQueue, queryClient]); - // Listen to socket events to update Past Imports table in real-time + // Listen to socket events to update Past Imports table useEffect(() => { const socket = getSocket("/"); const handleImportCompleted = () => { - console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports"); - // Small delay to ensure backend has committed the job results + console.log( + "[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports" + ); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); }, 1500); }; const handleQueueDrained = () => { - console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports"); - // Small delay to ensure backend has committed the job results + console.log( + "[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports" + ); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); }, 1500); @@ -159,14 +150,15 @@ export const Import = (): ReactElement => { const handleForceReImport = async () => { setImportError(null); - // Check for missing directories before starting if (!hasAllDirectories) { if (directoryCheckFailed) { setImportError( "Cannot start import: Failed to verify directory status. Please check that the backend service is running." ); } else { - const issueDetails = directoryIssues.map(i => `${i.directory}: ${i.issue}`).join(", "); + const issueDetails = directoryIssues + .map((i) => `${i.directory}: ${i.issue}`) + .join(", "); setImportError( `Cannot start import: ${issueDetails || "Required directories are missing"}. Please check your Docker volume configuration.` ); @@ -174,7 +166,6 @@ export const Import = (): ReactElement => { return; } - // Check for active session before starting using definitive status if (hasActiveSession) { setImportError( `Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.` @@ -182,10 +173,12 @@ export const Import = (): ReactElement => { return; } - if (window.confirm( - "This will re-import ALL files in your library folder, even those already imported. " + - "This can help fix Elasticsearch indexing issues. Continue?" - )) { + if ( + window.confirm( + "This will re-import ALL files in your library folder, even those already imported. " + + "This can help fix Elasticsearch indexing issues. Continue?" + ) + ) { if (importJobQueue.status === "drained") { localStorage.removeItem("sessionId"); disconnectSocket("/"); @@ -201,6 +194,10 @@ export const Import = (): ReactElement => { } }; + const canStartImport = + !hasActiveSession && + (importJobQueue.status === "drained" || importJobQueue.status === undefined); + return (
@@ -211,7 +208,6 @@ export const Import = (): ReactElement => {

Import

-

Import comics into the ThreeTwo library.

@@ -247,97 +243,72 @@ export const Import = (): ReactElement => { {/* Error Message */} {importError && ( -
-
- - - -
-

- Import Error -

-

- {importError} -

-
- -
+
+ setImportError(null)} + > + {importError} +
)} - {/* Directory Check Error - shown when API call fails */} + {/* Directory Check Error */} {!isCheckingDirectories && directoryCheckFailed && ( -
-
- - - -
-

- Failed to Check Directory Status -

-

- Unable to verify if required directories exist. Import functionality has been disabled. -

-

- Error: {(directoryError as Error)?.message || "Unknown error"} -

-
-
+
+ +

+ Unable to verify if required directories exist. Import + functionality has been disabled. +

+

+ Error: {(directoryError as Error)?.message || "Unknown error"} +

+
)} - {/* Directory Status Warning - shown when directories have issues */} - {!isCheckingDirectories && !directoryCheckFailed && directoryIssues.length > 0 && ( -
-
- - - -
-

- Directory Configuration Issues + {/* Directory Status Warning */} + {!isCheckingDirectories && + !directoryCheckFailed && + directoryIssues.length > 0 && ( +

+ +

+ The following issues were detected with your directory + configuration:

-

- The following issues were detected with your directory configuration: + +

+ Please ensure these directories are mounted correctly in your + Docker configuration.

-
    - {directoryIssues.map((item) => ( -
  • - {item.directory} - — {item.issue} -
  • - ))} -
-

- Please ensure these directories are mounted correctly in your Docker configuration. -

-
+
-
- )} + )} - {/* Force Re-Import Button - always shown when no import is running */} - {!hasActiveSession && - (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( + {/* Force Re-Import Button */} + {canStartImport && (
)} - {/* Import activity is now shown in the RealTimeImportStats component above */} - + {/* Past Imports Table */} {!isLoading && !isEmpty(data?.getJobResultStatistics) && ( -
- - - Past Imports - - - - -
- - - - - - - - - - - - - {data?.getJobResultStatistics.map((jobResult: any, index: number) => { - return ( - - - - - - - - ); - })} - -
- # - - Time Started - - Session Id - - Imported - - Failed -
- {index + 1} - - {jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp)) - ? format( - new Date(parseInt(jobResult.earliestTimestamp)), - "EEEE, hh:mma, do LLLL y", - ) - : "N/A"} - - - {jobResult.sessionId} - - - - - - -

- {jobResult.completedJobs} -

-
-
- - - - - -

- {jobResult.failedJobs} -

-
-
-
-
+ )}
@@ -434,4 +327,20 @@ export const Import = (): ReactElement => { ); }; +/** + * Helper component to render directory issues list. + */ +const DirectoryIssuesList = ({ issues }: { issues: DirectoryIssue[] }): ReactElement => ( + +); + export default Import; diff --git a/src/client/components/Import/PastImportsTable.tsx b/src/client/components/Import/PastImportsTable.tsx new file mode 100644 index 0000000..0e76a1f --- /dev/null +++ b/src/client/components/Import/PastImportsTable.tsx @@ -0,0 +1,103 @@ +/** + * @fileoverview Table component displaying historical import sessions. + * @module components/Import/PastImportsTable + */ + +import { ReactElement } from "react"; +import { format } from "date-fns"; +import type { JobResultStatistics } from "./import.types"; + +/** + * Props for the PastImportsTable component. + */ +export type PastImportsTableProps = { + /** Array of job result statistics from past imports */ + data: JobResultStatistics[]; +}; + +/** + * Displays a table of past import sessions with their statistics. + * + * @param props - Component props + * @returns Table element showing import history + */ +export const PastImportsTable = ({ data }: PastImportsTableProps): ReactElement => { + return ( +
+ + + Past Imports + + + + +
+ + + + + + + + + + + + + {data.map((jobResult, index) => ( + + + + + + + + ))} + +
+ # + + Time Started + + Session Id + + Imported + + Failed +
+ {index + 1} + + {jobResult.earliestTimestamp && + !isNaN(parseInt(jobResult.earliestTimestamp)) + ? format( + new Date(parseInt(jobResult.earliestTimestamp)), + "EEEE, hh:mma, do LLLL y" + ) + : "N/A"} + + {jobResult.sessionId} + + + + + +

+ {jobResult.completedJobs} +

+
+
+ + + + +

+ {jobResult.failedJobs} +

+
+
+
+
+ ); +}; + +export default PastImportsTable; diff --git a/src/client/components/Import/import.types.ts b/src/client/components/Import/import.types.ts new file mode 100644 index 0000000..57e11f5 --- /dev/null +++ b/src/client/components/Import/import.types.ts @@ -0,0 +1,43 @@ +/** + * @fileoverview Type definitions for the Import module. + * @module components/Import/import.types + */ + +/** + * Represents an issue with a configured directory. + */ +export type DirectoryIssue = { + /** Path to the directory with issues */ + directory: string; + /** Description of the issue */ + issue: string; +}; + +/** + * Result of directory status check from the backend. + */ +export type DirectoryStatus = { + /** Whether all required directories are accessible */ + isValid: boolean; + /** List of specific issues found */ + issues: DirectoryIssue[]; +}; + +/** + * Statistics for a completed import job session. + */ +export type JobResultStatistics = { + /** Unique session identifier */ + sessionId: string; + /** Timestamp of the earliest job in the session (as string for GraphQL compatibility) */ + earliestTimestamp: string; + /** Number of successfully completed jobs */ + completedJobs: number; + /** Number of failed jobs */ + failedJobs: number; +}; + +/** + * Status of the import job queue. + */ +export type ImportQueueStatus = "running" | "drained" | undefined; diff --git a/src/client/components/shared/AlertBanner.tsx b/src/client/components/shared/AlertBanner.tsx new file mode 100644 index 0000000..1dbb7c8 --- /dev/null +++ b/src/client/components/shared/AlertBanner.tsx @@ -0,0 +1,129 @@ +/** + * @fileoverview Reusable alert banner component for displaying status messages. + * @module components/shared/AlertBanner + */ + +import { ReactElement, ReactNode } from "react"; + +/** + * Alert severity levels that determine styling. + */ +export type AlertSeverity = "error" | "warning" | "info" | "success"; + +/** + * Props for the AlertBanner component. + */ +export type AlertBannerProps = { + /** Alert severity level */ + severity: AlertSeverity; + /** Alert title/heading */ + title: string; + /** Alert content - can be string or JSX */ + children: ReactNode; + /** Optional close handler - shows close button when provided */ + onClose?: () => void; + /** Optional custom icon class (defaults based on severity) */ + iconClass?: string; + /** Optional additional CSS classes */ + className?: string; +}; + +const severityConfig: Record< + AlertSeverity, + { + border: string; + bg: string; + titleColor: string; + textColor: string; + iconColor: string; + defaultIcon: string; + } +> = { + error: { + border: "border-red-500", + bg: "bg-red-50 dark:bg-red-900/20", + titleColor: "text-red-800 dark:text-red-300", + textColor: "text-red-700 dark:text-red-400", + iconColor: "text-red-600 dark:text-red-400", + defaultIcon: "icon-[solar--danger-circle-bold]", + }, + warning: { + border: "border-amber-500", + bg: "bg-amber-50 dark:bg-amber-900/20", + titleColor: "text-amber-800 dark:text-amber-300", + textColor: "text-amber-700 dark:text-amber-400", + iconColor: "text-amber-600 dark:text-amber-400", + defaultIcon: "icon-[solar--folder-error-bold]", + }, + info: { + border: "border-blue-500", + bg: "bg-blue-50 dark:bg-blue-900/20", + titleColor: "text-blue-800 dark:text-blue-300", + textColor: "text-blue-700 dark:text-blue-400", + iconColor: "text-blue-600 dark:text-blue-400", + defaultIcon: "icon-[solar--info-circle-bold]", + }, + success: { + border: "border-emerald-500", + bg: "bg-emerald-50 dark:bg-emerald-900/20", + titleColor: "text-emerald-800 dark:text-emerald-300", + textColor: "text-emerald-700 dark:text-emerald-400", + iconColor: "text-emerald-600 dark:text-emerald-400", + defaultIcon: "icon-[solar--check-circle-bold]", + }, +}; + +/** + * Reusable alert banner component for displaying status messages. + * + * @param props - Component props + * @returns Alert banner element + * + * @example + * ```tsx + * setError(null)}> + * Failed to import files. Please try again. + * + * ``` + */ +export const AlertBanner = ({ + severity, + title, + children, + onClose, + iconClass, + className = "", +}: AlertBannerProps): ReactElement => { + const config = severityConfig[severity]; + const icon = iconClass || config.defaultIcon; + + return ( +
+
+ + + +
+

{title}

+
{children}
+
+ {onClose && ( + + )} +
+
+ ); +}; + +export default AlertBanner;