Compare commits

...

11 Commits

23 changed files with 1119 additions and 982 deletions

View File

@@ -25,7 +25,7 @@
"@floating-ui/react-dom": "^2.1.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
"@types/react-router-dom": "^5.3.3",

View File

@@ -67,13 +67,8 @@ type ComicDetailProps = {
};
/**
* Component for displaying the metadata for a comic in greater detail.
*
* @component
* @example
* return (
* <ComicDetail/>
* )
* Displays full comic detail: cover, file info, action menu, and tabbed panels
* for metadata, archive operations, and acquisition.
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
@@ -84,7 +79,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
updatedAt,
},
userSettings,
queryClient,
@@ -94,24 +88,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Modal handlers (currently unused but kept for future use)
const openModal = useCallback((filePath: string) => {
setIsOpen(true);
}, []);
const afterOpenModal = useCallback((things: any) => {
// Modal opened callback
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// Action event handlers
const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine);
@@ -224,10 +204,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<div className="grid">
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
rawFileDetails,
inferredMetadata,
createdAt,
}}
>
{/* action dropdown */}

View File

@@ -1,12 +1,21 @@
import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult";
import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props;
interface ComicVineMatchPanelProps {
props: {
comicObjectId: string;
comicVineMatches: any[];
queryClient?: any;
onMatchApplied?: () => void;
};
}
/** Displays ComicVine search results or a status message while searching. */
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
const { comicvine } = useStore(
useShallow((state) => ({
comicvine: state.comicvine,

View File

@@ -1,55 +1,41 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { Form, Field } from "react-final-form";
import React, { ReactElement } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
interface EditMetadataPanelProps {
data: {
name?: string;
[key: string]: any;
};
}
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Sliding panel form for manually editing comic metadata fields. */
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
const onSubmit = async () => {};
const { data } = props;
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
// const rawFileDetails = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
// );
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
mutators={{
...arrayMutators,
}}
mutators={{ ...arrayMutators }}
render={({
handleSubmit,
form: {
mutators: { push, pop },
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
},
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
<p className="text-xs">Do not enter the first zero</p>
</div>
<div>
{/* year */}
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
{/* page count */}
{/* Description */}
<div className="mt-2">
<label className="text-sm">Description</label>
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
/>
</div>
<hr size="1" />
<hr />
<div className="field is-horizontal">
<div className="field-label">
@@ -153,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* Publisher */}
<div className="field is-horizontal">
@@ -224,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* team credits */}
<div className="field is-horizontal">
@@ -302,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>

View File

@@ -4,6 +4,7 @@ import { convert } from "html-to-text";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
import { useGetComicByIdQuery } from "../../graphql/generated";
interface MatchResultProps {
matchData: any;
@@ -31,7 +32,7 @@ export const MatchResult = (props: MatchResultProps) => {
// Invalidate and refetch the comic book metadata
if (props.queryClient) {
await props.queryClient.invalidateQueries({
queryKey: ["comicBookMetadata", comicObjectId],
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
});
}

View File

@@ -1,29 +1,24 @@
import React, { ReactElement, ReactNode } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
import { format, parseISO, isValid } from "date-fns";
import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
type RawFileDetailsProps = {
data?: {
rawFileDetails?: RawFileDetailsType;
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
inferredMetadata?: InferredMetadata;
createdAt?: string;
};
children?: ReactNode;
};
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data || {};
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
return (
<>
<div className="max-w-2xl ml-5">
@@ -97,10 +92,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{created_at ? (
{createdAt && isValid(parseISO(createdAt)) ? (
<>
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(createdAt), "h aaaa")}
</>
) : "N/A"}
</dd>

View File

@@ -1,105 +1,105 @@
import React, { ReactElement } from "react";
import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type LibraryStatisticsProps = {
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
comicDirectorySize: DirectorySize;
comicsMissingFiles: number;
};
export const LibraryStatistics = (
props: LibraryStatisticsProps,
): ReactElement => {
const { stats } = props;
/** Props for {@link LibraryStatistics}. */
interface LibraryStatisticsProps {
stats: Stats | null | undefined;
}
/**
* Displays a snapshot of library metrics: total comic files, tagging coverage,
* file-type breakdown, and the publisher with the most issues.
*
* Returns `null` when `stats` is absent or the statistics array is empty.
*/
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
if (!stats || !stats.totalDocuments) return null;
const facet = stats.statistics?.[0];
if (!facet) return null;
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
const topPublisher = publisherWithMostComicsInLibrary?.[0];
return (
<div className="mt-5">
<Header
headerContent="Your Library In Numbers"
subHeaderContent={
<span className="text-md">A brief snapshot of your library.</span>
}
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="mt-3">
<div className="flex flex-row gap-5">
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Library size</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</dd>
{props.stats.comicDirectorySize?.fileCount && (
<dd>
<span className="text-2xl text-green-600">
{props.stats.comicDirectorySize.fileCount} comic files
</span>
</dd>
)}
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issues) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issues?.length || 0}
</span>{" "}
tagged with ComicVine
</div>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0}
</span>{" "}
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
with ComicInfo.xml
</span>
</div>
)}
</div>
<div className="">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
return (
<span
key={idx}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
>
{fileType.data.length} {fileType.id}
</span>
);
})}
</div>
{/* file types */}
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
{/* publisher with most issues */}
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0],
) && (
<>
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.id
}
</span>
{" has the most issues "}
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.count
}
</span>
</>
)}
</div>
<div className="mt-3 flex flex-row gap-5">
{/* Total records in database */}
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.totalDocuments} comics
</dd>
</div>
{/* Missing files */}
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
<dd className="text-3xl text-red-600 md:text-5xl">
{stats.comicsMissingFiles}
</dd>
</div>
{/* Disk space consumed */}
{stats.comicDirectorySize.totalSizeInGB != null && (
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
</dd>
</div>
)}
{/* Tagging coverage */}
<div className="flex flex-col gap-4">
{issues && issues.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issues.length}</span>
tagged with ComicVine
</div>
)}
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
with ComicInfo.xml
</div>
)}
</div>
{/* File-type breakdown */}
{fileTypes && fileTypes.length > 0 && (
<div>
{fileTypes.map((ft) => (
<span
key={ft.id}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
>
{ft.data.length} {ft.id}
</span>
))}
</div>
)}
{/* Publisher with most issues */}
{topPublisher && (
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
<span>{topPublisher.id}</span>
{" has the most issues "}
<span>{topPublisher.count}</span>
</div>
)}
</div>
</div>
);

View File

@@ -42,6 +42,7 @@ export const RecentlyImported = (
sourcedMetadata,
canonicalMetadata,
inferredMetadata,
importStatus,
} = comic;
// Parse sourced metadata (GraphQL returns as strings)
@@ -63,7 +64,10 @@ export const RecentlyImported = (
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
return (
<div
key={idx}
@@ -127,12 +131,6 @@ export const RecentlyImported = (
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
</div>

View File

@@ -11,9 +11,11 @@ type VolumeGroupsProps = {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
};
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
const navigate = useNavigate();
const navigateToVolumes = (row: any) => {
navigate(`/volumes/all`);

View File

@@ -15,7 +15,9 @@ type WantedComicsListProps = {
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
}: WantedComicsListProps): ReactElement | null => {
if (!comics || comics.length === 0) return null;
const navigate = useNavigate();
// embla carousel

View File

@@ -1,30 +1,17 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import {
useGetJobResultStatisticsQuery,
useGetImportStatisticsQuery,
useStartIncrementalImportMutation
} from "../../graphql/generated";
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
interface ImportProps {
path: string;
}
/**
* Import component for adding comics to the ThreeTwo library.
* Provides preview statistics, smart import, and queue management.
*/
export const Import = (props: ImportProps): ReactElement => {
const queryClient = useQueryClient();
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
@@ -33,30 +20,6 @@ export const Import = (props: ImportProps): ReactElement => {
})),
);
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const { mutate: initiateImport } = useMutation({
mutationFn: async () => {
const sessionId = localStorage.getItem("sessionId");
return await axios.request({
url: `http://localhost:3000/api/library/newImport`,
method: "POST",
data: { sessionId },
});
},
});
// Force re-import mutation - re-imports all files regardless of import status
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
mutationFn: async () => {
@@ -78,101 +41,57 @@ export const Import = (props: ImportProps): ReactElement => {
},
});
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
// Get import statistics to determine if Start Import button should be shown
const { data: importStats } = useGetImportStatisticsQuery(
{},
{
refetchOnWindowFocus: false,
refetchInterval: false,
}
);
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
// Use custom hook for definitive import session status tracking
// NO POLLING - relies on Socket.IO events only
const importSession = useImportSessionStatus();
const hasActiveSession = importSession.isActive;
const wasComplete = useRef(false);
// Determine if we should show the Start Import button
const hasNewFiles = importStats?.getImportStatistics?.success &&
importStats.getImportStatistics.stats &&
importStats.getImportStatistics.stats.newFiles > 0;
// React to importSession.isComplete rather than socket events — more reliable
// since it's derived from the actual GraphQL state, not a raw socket event.
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);
importJobQueue.setStatus("drained");
} else if (!importSession.isComplete) {
wasComplete.current = false;
}
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
// Listen to socket events to update Past Imports table in real-time
useEffect(() => {
const socket = getSocket("/");
const handleQueueDrained = () => refetch();
const handleCoverExtracted = () => refetch();
const handleSessionStarted = () => {
importJobQueue.setStatus("running");
const handleImportCompleted = () => {
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
const handleSessionCompleted = () => {
refetch();
importJobQueue.setStatus("drained");
const handleQueueDrained = () => {
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
};
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]);
/**
* Toggles import queue pause/resume state
*/
const toggleQueue = (queueAction: string, queueStatus: string) => {
const socket = getSocket("/");
socket.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
);
};
/**
* Starts smart import with race condition prevention
*/
const handleStartSmartImport = async () => {
// Clear any previous errors
setImportError(null);
// 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.`
);
return;
}
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}, 500);
}, 100);
} else {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}
};
}, [getSocket, queryClient]);
/**
* Handles force re-import - re-imports all files to fix indexing issues
@@ -197,7 +116,6 @@ export const Import = (props: ImportProps): ReactElement => {
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
forceReImport();
}, 500);
@@ -208,54 +126,6 @@ export const Import = (props: ImportProps): ReactElement => {
}
};
/**
* Renders pause/resume controls based on queue status
*/
const renderQueueControls = (status: string): ReactElement | null => {
switch (status) {
case "running":
return (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("pause", "paused");
importJobQueue.setStatus("paused");
}}
>
<span className="text-md">Pause</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button>
</div>
);
case "paused":
return (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("resume", "running");
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Resume</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button>
</div>
);
case "drained":
return null;
default:
return null;
}
};
return (
<div>
<section>
@@ -327,53 +197,10 @@ export const Import = (props: ImportProps): ReactElement => {
</div>
)}
{/* Active Session Warning */}
{hasActiveSession && !hasNewFiles && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-yellow-600 dark:text-yellow-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--info-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-yellow-800 dark:text-yellow-300">
Import In Progress
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
An import session is currently active. New imports cannot be started until it completes.
</p>
</div>
</div>
</div>
)}
{/* Import Action Buttons */}
<div className="my-6 max-w-screen-lg flex flex-col sm:flex-row gap-3">
{/* Start Smart Import Button - shown when there are new files, no active session, and no import is running */}
{hasNewFiles &&
!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleStartSmartImport}
disabled={isStartingImport || hasActiveSession}
>
<span className="text-md font-medium">
{isStartingImport
? "Starting Import..."
: importStats?.getImportStatistics?.stats?.alreadyImported === 0
? `Start Import (${importStats?.getImportStatistics?.stats?.newFiles} files)`
: `Start Incremental Import (${importStats?.getImportStatistics?.stats?.newFiles} new files)`
}
</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<div className="my-6 max-w-screen-lg">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleForceReImport}
@@ -387,8 +214,8 @@ export const Import = (props: ImportProps): ReactElement => {
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
</span>
</button>
)}
</div>
</div>
)}
{/* Import activity is now shown in the RealTimeImportStats component above */}

View File

@@ -1,93 +1,167 @@
import React, { ReactElement, useEffect, useState } from "react";
import { ReactElement, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
useGetImportStatisticsQuery,
useStartIncrementalImportMutation
useGetWantedComicsQuery,
useStartIncrementalImportMutation,
} from "../../graphql/generated";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
/**
* Import statistics with card-based layout and progress bar
* Updates in real-time via the useImportSessionStatus hook
* Import statistics with card-based layout and progress bar.
* Three states: pre-import (idle), importing (active), and post-import (complete).
* Also surfaces missing files detected by the file watcher.
*/
export const RealTimeImportStats = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const [detectedFile, setDetectedFile] = useState<string | null>(null);
const [socketImport, setSocketImport] = useState<{
active: boolean;
completed: number;
total: number;
failed: number;
} | null>(null);
const queryClient = useQueryClient();
const { getSocket, disconnectSocket, importJobQueue } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
importJobQueue: state.importJobQueue,
}))
})),
);
// Get filesystem statistics (new files vs already imported)
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false }
{ refetchOnWindowFocus: false, refetchInterval: false },
);
// Get definitive import session status (handles Socket.IO events internally)
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const stats = importStats?.getImportStatistics?.stats;
// File list for the detail panel — only fetched when there are missing files
const { data: missingComicsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 3, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{
refetchOnWindowFocus: false,
refetchInterval: false,
enabled: (stats?.missingFiles ?? 0) > 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) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
setImportError(
error?.message || "Failed to start import. Please try again.",
);
},
});
const hasNewFiles = stats && stats.newFiles > 0;
const missingCount = stats?.missingFiles ?? 0;
// Refetch filesystem stats when import completes
useEffect(() => {
if (importSession.isComplete && importSession.status === "completed") {
console.log("[RealTimeImportStats] Import completed, refetching filesystem stats");
refetchStats();
importJobQueue.setStatus("drained");
}
}, [importSession.isComplete, importSession.status, refetchStats, importJobQueue]);
// Listen to filesystem change events to refetch stats
// 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 handleFilesystemChange = () => {
refetchStats();
const handleStatsChange = () => {
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
};
// File system changes that affect import statistics
socket.on("LS_FILE_ADDED", handleFilesystemChange);
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
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_FILE_ADDED", handleFilesystemChange);
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
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, refetchStats]);
}, [getSocket, queryClient]);
const handleStartImport = async () => {
setImportError(null);
// Check if import is already active using definitive status
if (importSession.isActive) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
);
return;
}
@@ -112,24 +186,26 @@ export const RealTimeImportStats = (): ReactElement => {
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
}
// Determine button text based on whether there are already imported files
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`
: `Start Incremental Import (${stats.newFiles} new files)`;
// Calculate display statistics
const displayStats = importSession.isActive && importSession.stats
? {
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
filesQueued: importSession.stats.filesQueued,
filesSucceeded: importSession.stats.filesSucceeded,
}
: {
totalFiles: stats.totalLocalFiles,
filesQueued: stats.newFiles,
filesSucceeded: stats.alreadyImported,
};
// 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 (
<div className="space-y-6">
@@ -141,89 +217,161 @@ export const RealTimeImportStats = (): ReactElement => {
<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>
<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"
>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</span>
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</button>
</div>
</div>
)}
{/* Import Button - only show when there are new files and no active import */}
{/* File detected toast */}
{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">
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
New file detected: {detectedFile}
</p>
</div>
)}
{/* Start Import button — only when idle with new files */}
{hasNewFiles && !importSession.isActive && (
<button
onClick={handleStartImport}
disabled={isStartingImport}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
</button>
)}
{/* Active Import Progress Bar */}
{importSession.isActive && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
{/* Progress bar — shown while importing and once complete */}
{showProgressBar && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{socketImport!.active
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
: `${socketImport!.completed} / ${socketImport!.total} imported`}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{Math.round(importSession.progress)}%
<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: `${importSession.progress}%` }}
style={{ width: `${socketProgressPct}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
{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-1 sm:grid-cols-3 gap-4">
{/* Files Detected Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.totalFiles}
</div>
<div className="text-sm text-gray-200 font-medium">
files detected
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Total files */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#6b7280" }}
>
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
<div className="text-sm text-gray-200 font-medium">in import folder</div>
</div>
{/* To Import Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.filesQueued}
</div>
<div className="text-sm text-gray-100 font-medium">
to import
</div>
</div>
{/* Already Imported Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#d8dab2' }}>
{/* Imported */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#d8dab2" }}
>
<div className="text-4xl font-bold text-gray-800 mb-2">
{displayStats.filesSucceeded}
{importedCount}
</div>
<div className="text-sm text-gray-700 font-medium">
already imported
{importSession.isActive ? "imported so far" : "imported in database"}
</div>
</div>
{/* Failed — only shown after a session with failures */}
{showFailedCard && (
<div className="rounded-lg p-6 text-center bg-red-500">
<div className="text-4xl font-bold text-white mb-2">
{failedCount}
</div>
<div className="text-sm text-red-100 font-medium">failed</div>
</div>
)}
{/* Missing files — shown when watcher detects moved/deleted files */}
{showMissingCard && (
<div className="rounded-lg p-6 text-center bg-card-missing">
<div className="text-4xl font-bold text-slate-700 mb-2">
{missingCount}
</div>
<div className="text-sm text-slate-800 font-medium">missing</div>
</div>
)}
</div>
{/* Missing files detail panel */}
{showMissingCard && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-800 dark:text-amber-300">
{missingCount} {missingCount === 1 ? "file" : "files"} missing
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
These files were previously imported but can no longer be found
on disk. Move them back to restore access.
</p>
{missingDocs.length > 0 && (
<ul className="mt-2 space-y-1">
{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
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"
>
<span className="underline">
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
View Missing Files In Library
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
</span>
</Link>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import React, { useMemo, ReactElement, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
@@ -12,79 +11,130 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
import { format, parseISO } from "date-fns";
import { useGetWantedComicsQuery } from "../../graphql/generated";
type FilterOption = "all" | "missingFiles";
interface SearchQuery {
query: Record<string, any>;
pagination: { size: number; from: number };
type: string;
trigger: string;
}
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: "all", label: "All Comics" },
{ value: "missingFiles", label: "Missing Files" },
];
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
* Library page component. Displays a paginated, searchable table of all comics
* in the collection, with an optional filter for comics with missing raw files.
*/
export const Library = (): ReactElement => {
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({
const [searchParams] = useSearchParams();
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {},
pagination: {
size: 25,
from: offset,
},
pagination: { size: 25, from: 0 },
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/**
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
/** Fetches a page of issues from the search API. */
const fetchIssues = async (q: SearchQuery) => {
const { pagination, query, type } = q;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
query,
pagination,
type,
},
data: { query, pagination, type },
});
};
const searchIssues = (e) => {
const { data, isPlaceholderData } = useQuery({
queryKey: ["comics", searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
enabled: activeFilter === "all",
});
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 25, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "missingFiles" },
);
const { data: missingIdsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 1000, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "all" },
);
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
const missingIdSet = useMemo(
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
[missingIdsData],
);
const searchResults = data?.data;
const navigate = useNavigate();
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
/** Triggers a search by volume name and resets pagination. */
const searchIssues = (e: any) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
query: { volumeName: e.search },
pagination: { size: 15, from: 0 },
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
/** Advances to the next page of results. */
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from: pageSize * pageIndex + 1 },
type: "all",
trigger: "libraryPage",
});
}
};
const ComicInfoXML = (value) => {
return value.data ? (
/** Goes back to the previous page of results. */
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from },
type: "all",
trigger: "libraryPage",
});
};
const ComicInfoXML = (value: any) =>
value.data ? (
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
{/* Series Name */}
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
@@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
</span>
</span>
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
{/* Pages */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
@@ -103,7 +152,6 @@ export const Library = (): ReactElement => {
Pages: {value.data.pagecount[0]}
</span>
</span>
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
@@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
</div>
</dl>
) : null;
};
const missingFilesColumns = useMemo(
() => [
{
header: "Missing Files",
columns: [
{
header: "Status",
id: "missingStatus",
cell: () => (
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
MISSING
</span>
</div>
),
},
{
header: "Comic",
id: "missingComic",
minWidth: 250,
accessorFn: (row: any) => row,
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
},
],
},
],
[],
);
const columns = useMemo(
() => [
{
header: "Comic Metadata",
footer: 1,
columns: [
{
header: "File Details",
id: "fileDetails",
minWidth: 250,
accessorKey: "_source",
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
cell: (info: any) => {
const source = info.getValue();
return (
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : null,
cell: (info: any) =>
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
},
],
},
@@ -150,36 +230,30 @@ export const Library = (): ReactElement => {
{
header: "Date of Import",
accessorKey: "_source.createdAt",
cell: (info) => {
return !isNil(info.getValue()) ? (
cell: (info: any) =>
!isNil(info.getValue()) ? (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
{format(parseISO(info.getValue()), "h aaaa")}
</div>
) : null;
},
) : null,
},
{
header: "Downloads",
accessorKey: "_source.acquisition",
cell: (info) => (
cell: (info: any) => (
<div className="flex flex-col gap-2 ml-3 my-3">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
DC++: {info.getValue().directconnect.downloads.length}
</span>
DC++: {info.getValue().directconnect.downloads.length}
</span>
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.length}
</span>
Torrent: {info.getValue().torrent.length}
</span>
</div>
),
@@ -187,130 +261,100 @@ export const Library = (): ReactElement => {
],
},
],
[],
[missingIdSet],
);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
}
};
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(from);
};
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{!isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<div>
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
>
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div>
</div>
) : (
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come
back.
</p>
</div>
</article>
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
{!isUndefined(searchResults?.data?.meta?.body) ? (
<p>
{JSON.stringify(
searchResults?.data.meta.body.error.root_cause,
null,
4,
)}
</p>
) : null}
</pre>
</div>
</div>
)}
</section>
const FilterDropdown = () => (
<div className="relative">
<select
value={activeFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
</div>
);
const isMissingFilter = activeFilter === "missingFiles";
return (
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{isMissingFilter ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{isMissingLoading ? (
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
) : (
<T2Table
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
columns={missingFilesColumns}
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
rowClickHandler={navigateToMissingComicDetail}
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
>
<FilterDropdown />
</T2Table>
)}
</div>
) : !isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
getRowClassName={(row) =>
missingIdSet.has(row.original._id)
? "bg-card-missing/40 hover:bg-card-missing/20"
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
}
paginationHandlers={{ nextPage, previousPage }}
>
<div className="flex items-center gap-2">
<FilterDropdown />
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
</div>
</T2Table>
</div>
) : (
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no indices. Try
importing a few comics into the library and come back.
</p>
</div>
</article>
<FilterDropdown />
</div>
)}
</section>
);
};
export default Library;

View File

@@ -10,7 +10,7 @@ interface ICardProps {
children?: PropTypes.ReactNodeLike;
borderColorClass?: string;
backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: React.CSSProperties;
imageStyle?: React.CSSProperties;
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
return "bg-card-uncompressed";
case "imported":
return "bg-card-imported";
case "missing":
return "bg-card-missing";
default:
return "";
}
@@ -102,11 +104,22 @@ const renderCard = (props: ICardProps): ReactElement => {
case "vertical-2":
return (
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
<div className="relative">
{props.imageUrl ? (
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
) : (
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
)}
{props.cardState === "missing" && (
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
<i className="icon-[solar--file-corrupted-outline] w-16 h-16 text-red-500" />
</div>
)}
</div>
{props.title ? (
<div className="px-3 pt-3 mb-2">

View File

@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash";
interface IMetadatPanelProps {
value: any;
children: any;
imageStyle: any;
titleStyle: any;
tagsStyle: any;
containerStyle: any;
data: any;
value?: any;
children?: any;
imageStyle?: any;
titleStyle?: any;
tagsStyle?: any;
containerStyle?: any;
isMissing?: boolean;
}
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const { isMissing = false } = props;
const {
rawFileDetails,
inferredMetadata,
@@ -31,8 +34,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{
name: "rawFileDetails",
content: () => (
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
<dt>
<dl
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
>
<dt className="flex items-center gap-2">
<p className="text-sm sm:text-lg">{issueName}</p>
</dt>
<dd className="text-xs sm:text-sm">
@@ -58,26 +63,35 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)}
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
{rawFileDetails.mimeType && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
)}
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
{rawFileDetails.fileSize != null && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
)}
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
{/* Missing file Icon */}
{isMissing && (
<span className="pr-2 pt-1" title="File backing this comic is missing">
<i className="icon-[solar--file-corrupted-outline] w-5 h-5 text-red-600 shrink-0"></i>
</span>
</span>
)}
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
@@ -177,7 +191,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const metadataPanel = find(metadataContentPanel, {
name: objectReference,
});
return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
@@ -188,7 +201,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
imageStyle={props.imageStyle}
/>
</div>
<div className="flex-1">{metadataPanel.content()}</div>
<div className="flex-1">{metadataPanel?.content()}</div>
</div>
);
};

View File

@@ -1,69 +1,89 @@
import React, { ReactElement, useMemo, useState } from "react";
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import {
ColumnDef,
Row,
flexRender,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
PaginationState,
} from "@tanstack/react-table";
interface T2TableProps {
sourceData?: unknown[];
/** Props for {@link T2Table}. */
interface T2TableProps<TData> {
/** Row data to render. */
sourceData?: TData[];
/** Total number of records across all pages, used for pagination display. */
totalPages?: number;
columns?: unknown[];
/** Column definitions (TanStack Table {@link ColumnDef} array). */
columns?: ColumnDef<TData>[];
/** Callbacks for navigating between pages. */
paginationHandlers?: {
nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown;
nextPage?(pageIndex: number, pageSize: number): void;
previousPage?(pageIndex: number, pageSize: number): void;
};
rowClickHandler?(...args: unknown[]): unknown;
children?: any;
/** Called with the TanStack row object when a row is clicked. */
rowClickHandler?(row: Row<TData>): void;
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
getRowClassName?(row: Row<TData>): string;
/** Optional slot rendered in the toolbar area (e.g. a search input). */
children?: ReactNode;
}
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const {
sourceData,
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
} = tableOptions;
/**
* A paginated data table with a two-row sticky header.
*
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
* element placed immediately before the table. The second header row's `top`
* offset is measured at mount time so both rows stay flush regardless of font
* size or padding changes.
*/
export const T2Table = <TData,>({
sourceData = [],
columns = [],
paginationHandlers: { nextPage, previousPage } = {},
totalPages = 0,
rowClickHandler,
getRowClassName,
children,
}: T2TableProps<TData>): ReactElement => {
const sentinelRef = useRef<HTMLDivElement>(null);
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
const [isSticky, setIsSticky] = useState(false);
const [firstRowHeight, setFirstRowHeight] = useState(0);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => setIsSticky(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (firstHeaderRowRef.current)
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
}, []);
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
/**
* Pagination control to move forward one page
* @returns void
*/
/** Advances to the next page and notifies the parent. */
const goToNextPage = () => {
setPagination({
pageIndex: pageIndex + 1,
pageSize,
});
nextPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex + 1, pageSize });
nextPage?.(pageIndex, pageSize);
};
/**
* Pagination control to move backward one page
* @returns void
**/
/** Goes back one page and notifies the parent. */
const goToPreviousPage = () => {
setPagination({
pageIndex: pageIndex - 1,
pageSize,
});
previousPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex - 1, pageSize });
previousPage?.(pageIndex, pageSize);
};
const table = useReactTable({
@@ -72,63 +92,62 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1,
state: {
pagination,
},
state: { pagination },
onPaginationChange: setPagination,
});
return (
<div className="container max-w-fit">
<div>
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{/* Search bar */}
{tableOptions.children}
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{children}
{/* Pagination controls */}
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={() => goToPreviousPage()}
disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
>
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
</button>
<button
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
</button>
</div>
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={goToPreviousPage}
disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
>
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
</button>
<button
onClick={goToNextPage}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
</button>
</div>
</div>
</div>
<div ref={sentinelRef} />
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900">
<thead>
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, index) => (
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400 border-b border-gray-300 dark:border-slate-700"
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
className={[
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
groupIndex === 0
? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
].join(' ')}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
@@ -136,11 +155,11 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
</thead>
<tbody>
{table.getRowModel().rows.map((row, rowIndex) => (
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => rowClickHandler(row)}
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer"
onClick={() => rowClickHandler?.(row)}
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top">

View File

@@ -28,6 +28,23 @@ export type AcquisitionSourceInput = {
wanted?: InputMaybe<Scalars['Boolean']['input']>;
};
export type AddTorrentInput = {
comicObjectId: Scalars['ID']['input'];
torrentToDownload: Scalars['String']['input'];
};
export type AddTorrentResult = {
__typename?: 'AddTorrentResult';
result?: Maybe<Scalars['JSON']['output']>;
};
export type AppSettings = {
__typename?: 'AppSettings';
bittorrent?: Maybe<BittorrentSettings>;
directConnect?: Maybe<DirectConnectSettings>;
prowlarr?: Maybe<ProwlarrSettings>;
};
export type Archive = {
__typename?: 'Archive';
expandedPath?: Maybe<Scalars['String']['output']>;
@@ -52,6 +69,26 @@ export type AutoMergeSettingsInput = {
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
};
export type BittorrentClient = {
__typename?: 'BittorrentClient';
host?: Maybe<HostConfig>;
name?: Maybe<Scalars['String']['output']>;
};
export type BittorrentSettings = {
__typename?: 'BittorrentSettings';
client?: Maybe<BittorrentClient>;
};
export type Bundle = {
__typename?: 'Bundle';
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
size?: Maybe<Scalars['String']['output']>;
speed?: Maybe<Scalars['String']['output']>;
status?: Maybe<Scalars['String']['output']>;
};
export type CanonicalMetadata = {
__typename?: 'CanonicalMetadata';
ageRating?: Maybe<MetadataField>;
@@ -123,6 +160,11 @@ export type ComicConnection = {
totalCount: Scalars['Int']['output'];
};
export type ComicVineMatchInput = {
volume: ComicVineVolumeRefInput;
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
};
export type ComicVineResourceResponse = {
__typename?: 'ComicVineResourceResponse';
error: Scalars['String']['output'];
@@ -143,6 +185,24 @@ export type ComicVineSearchResult = {
offset: Scalars['Int']['output'];
results: Array<SearchResultItem>;
status_code: Scalars['Int']['output'];
total: Scalars['Int']['output'];
};
export type ComicVineVolume = {
__typename?: 'ComicVineVolume';
api_detail_url?: Maybe<Scalars['String']['output']>;
count_of_issues?: Maybe<Scalars['Int']['output']>;
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
image?: Maybe<VolumeImage>;
name?: Maybe<Scalars['String']['output']>;
publisher?: Maybe<Publisher>;
site_detail_url?: Maybe<Scalars['String']['output']>;
start_year?: Maybe<Scalars['String']['output']>;
};
export type ComicVineVolumeRefInput = {
api_detail_url: Scalars['String']['input'];
};
export enum ConflictResolutionStrategy {
@@ -177,10 +237,22 @@ export type DirectConnectBundleInput = {
size?: InputMaybe<Scalars['String']['input']>;
};
export type DirectConnectClient = {
__typename?: 'DirectConnectClient';
airDCPPUserSettings?: Maybe<Scalars['JSON']['output']>;
host?: Maybe<HostConfig>;
hubs?: Maybe<Array<Maybe<Scalars['JSON']['output']>>>;
};
export type DirectConnectInput = {
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
};
export type DirectConnectSettings = {
__typename?: 'DirectConnectSettings';
client?: Maybe<DirectConnectClient>;
};
export type DirectorySize = {
__typename?: 'DirectorySize';
fileCount: Scalars['Int']['output'];
@@ -234,6 +306,37 @@ export type GetVolumesInput = {
volumeURI: Scalars['String']['input'];
};
export type HostConfig = {
__typename?: 'HostConfig';
hostname?: Maybe<Scalars['String']['output']>;
password?: Maybe<Scalars['String']['output']>;
port?: Maybe<Scalars['String']['output']>;
protocol?: Maybe<Scalars['String']['output']>;
username?: Maybe<Scalars['String']['output']>;
};
export type HostInput = {
hostname: Scalars['String']['input'];
password: Scalars['String']['input'];
port: Scalars['String']['input'];
protocol: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type Hub = {
__typename?: 'Hub';
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
userCount?: Maybe<Scalars['Int']['output']>;
};
export type ImageAnalysisResult = {
__typename?: 'ImageAnalysisResult';
analyzedData?: Maybe<Scalars['JSON']['output']>;
colorHistogramData?: Maybe<Scalars['JSON']['output']>;
};
export type ImageUrls = {
__typename?: 'ImageUrls';
icon_url?: Maybe<Scalars['String']['output']>;
@@ -303,6 +406,7 @@ export type ImportStatistics = {
export type ImportStats = {
__typename?: 'ImportStats';
alreadyImported: Scalars['Int']['output'];
missingFiles: Scalars['Int']['output'];
newFiles: Scalars['Int']['output'];
percentageImported: Scalars['String']['output'];
totalLocalFiles: Scalars['Int']['output'];
@@ -311,6 +415,7 @@ export type ImportStats = {
export type ImportStatus = {
__typename?: 'ImportStatus';
isImported?: Maybe<Scalars['Boolean']['output']>;
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
matchedResult?: Maybe<MatchedResult>;
tagged?: Maybe<Scalars['Boolean']['output']>;
};
@@ -421,6 +526,7 @@ export type LocgMetadataInput = {
export type LibraryStatistics = {
__typename?: 'LibraryStatistics';
comicDirectorySize: DirectorySize;
comicsMissingFiles: Scalars['Int']['output'];
statistics: Array<StatisticsFacet>;
totalDocuments: Scalars['Int']['output'];
};
@@ -515,6 +621,10 @@ export type Mutation = {
__typename?: 'Mutation';
/** Placeholder for future mutations */
_empty?: Maybe<Scalars['String']['output']>;
/** Add a torrent to qBittorrent */
addTorrent?: Maybe<AddTorrentResult>;
analyzeImage: ImageAnalysisResult;
applyComicVineMatch: Comic;
bulkResolveMetadata: Array<Comic>;
forceCompleteSession: ForceCompleteResult;
importComic: ImportComicResult;
@@ -524,11 +634,28 @@ export type Mutation = {
setMetadataField: Comic;
startIncrementalImport: IncrementalImportResult;
startNewImport: ImportJobResult;
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
updateSourcedMetadata: Comic;
updateUserPreferences: UserPreferences;
};
export type MutationAddTorrentArgs = {
input: AddTorrentInput;
};
export type MutationAnalyzeImageArgs = {
imageFilePath: Scalars['String']['input'];
};
export type MutationApplyComicVineMatchArgs = {
comicObjectId: Scalars['ID']['input'];
match: ComicVineMatchInput;
};
export type MutationBulkResolveMetadataArgs = {
comicIds: Array<Scalars['ID']['input']>;
};
@@ -579,6 +706,13 @@ export type MutationStartNewImportArgs = {
};
export type MutationUncompressArchiveArgs = {
comicObjectId: Scalars['ID']['input'];
filePath: Scalars['String']['input'];
options?: InputMaybe<Scalars['JSON']['input']>;
};
export type MutationUpdateSourcedMetadataArgs = {
comicId: Scalars['ID']['input'];
metadata: Scalars['String']['input'];
@@ -627,6 +761,17 @@ export type Provenance = {
url?: Maybe<Scalars['String']['output']>;
};
export type ProwlarrClient = {
__typename?: 'ProwlarrClient';
apiKey?: Maybe<Scalars['String']['output']>;
host?: Maybe<HostConfig>;
};
export type ProwlarrSettings = {
__typename?: 'ProwlarrSettings';
client?: Maybe<ProwlarrClient>;
};
export type Publisher = {
__typename?: 'Publisher';
api_detail_url?: Maybe<Scalars['String']['output']>;
@@ -642,7 +787,9 @@ export type PublisherStats = {
export type Query = {
__typename?: 'Query';
_empty?: Maybe<Scalars['String']['output']>;
analyzeMetadataConflicts: Array<MetadataConflict>;
bundles: Array<Bundle>;
comic?: Maybe<Comic>;
comics: ComicConnection;
/** Fetch resource from Metron API */
@@ -663,13 +810,18 @@ export type Query = {
getVolume: VolumeDetailResponse;
/** Get weekly pull list from League of Comic Geeks */
getWeeklyPullList: MetadataPullListResponse;
hubs: Array<Hub>;
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
/** Search ComicVine for volumes, issues, characters, etc. */
searchComicVine: ComicVineSearchResult;
searchIssue: SearchIssueResult;
searchTorrents: Array<TorrentSearchResult>;
settings?: Maybe<AppSettings>;
torrentJobs?: Maybe<TorrentJob>;
userPreferences?: Maybe<UserPreferences>;
/** Advanced volume-based search with scoring and filtering */
volumeBasedSearch: VolumeBasedSearchResponse;
walkFolders: Array<Scalars['String']['output']>;
};
@@ -678,6 +830,12 @@ export type QueryAnalyzeMetadataConflictsArgs = {
};
export type QueryBundlesArgs = {
comicObjectId: Scalars['ID']['input'];
config?: InputMaybe<Scalars['JSON']['input']>;
};
export type QueryComicArgs = {
id: Scalars['ID']['input'];
};
@@ -733,6 +891,11 @@ export type QueryGetWeeklyPullListArgs = {
};
export type QueryHubsArgs = {
host: HostInput;
};
export type QueryPreviewCanonicalMetadataArgs = {
comicId: Scalars['ID']['input'];
preferences?: InputMaybe<UserPreferencesInput>;
@@ -751,6 +914,21 @@ export type QuerySearchIssueArgs = {
};
export type QuerySearchTorrentsArgs = {
query: Scalars['String']['input'];
};
export type QuerySettingsArgs = {
settingsKey?: InputMaybe<Scalars['String']['input']>;
};
export type QueryTorrentJobsArgs = {
trigger: Scalars['String']['input'];
};
export type QueryUserPreferencesArgs = {
userId?: InputMaybe<Scalars['String']['input']>;
};
@@ -760,6 +938,12 @@ export type QueryVolumeBasedSearchArgs = {
input: VolumeSearchInput;
};
export type QueryWalkFoldersArgs = {
basePathToWalk: Scalars['String']['input'];
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type RawFileDetails = {
__typename?: 'RawFileDetails';
archive?: Maybe<Archive>;
@@ -938,6 +1122,24 @@ export type TeamCredit = {
site_detail_url?: Maybe<Scalars['String']['output']>;
};
export type TorrentJob = {
__typename?: 'TorrentJob';
id?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
};
export type TorrentSearchResult = {
__typename?: 'TorrentSearchResult';
downloadUrl?: Maybe<Scalars['String']['output']>;
guid?: Maybe<Scalars['String']['output']>;
indexer?: Maybe<Scalars['String']['output']>;
leechers?: Maybe<Scalars['Int']['output']>;
publishDate?: Maybe<Scalars['String']['output']>;
seeders?: Maybe<Scalars['Int']['output']>;
size?: Maybe<Scalars['Float']['output']>;
title?: Maybe<Scalars['String']['output']>;
};
export type UserPreferences = {
__typename?: 'UserPreferences';
autoMerge: AutoMergeSettings;
@@ -1083,7 +1285,7 @@ export type GetRecentComicsQueryVariables = Exact<{
}>;
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null }> } };
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null, importStatus?: { __typename?: 'ImportStatus', isRawFileMissing?: boolean | null } | null }> } };
export type GetWantedComicsQueryVariables = Exact<{
paginationOptions: PaginationOptionsInput;
@@ -1101,7 +1303,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicsMissingFiles: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number, totalSizeInGB: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
export type GetWeeklyPullListQueryVariables = Exact<{
input: WeeklyPullListInput;
@@ -1115,7 +1317,7 @@ export type GetImportStatisticsQueryVariables = Exact<{
}>;
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } };
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, missingFiles: number, percentageImported: string } } };
export type StartNewImportMutationVariables = Exact<{
sessionId: Scalars['String']['input'];
@@ -1524,6 +1726,9 @@ export const GetRecentComicsDocument = `
value
}
}
importStatus {
isRawFileMissing
}
createdAt
updatedAt
}
@@ -1739,8 +1944,10 @@ export const GetLibraryStatisticsDocument = `
query GetLibraryStatistics {
getLibraryStatistics {
totalDocuments
comicsMissingFiles
comicDirectorySize {
fileCount
totalSizeInGB
}
statistics {
fileTypes {
@@ -1874,6 +2081,7 @@ export const GetImportStatisticsDocument = `
totalLocalFiles
alreadyImported
newFiles
missingFiles
percentageImported
}
}

View File

@@ -93,6 +93,9 @@ query GetRecentComics($limit: Int) {
value
}
}
importStatus {
isRawFileMissing
}
createdAt
updatedAt
}
@@ -176,8 +179,10 @@ query GetVolumeGroups {
query GetLibraryStatistics {
getLibraryStatistics {
totalDocuments
comicsMissingFiles
comicDirectorySize {
fileCount
totalSizeInGB
}
statistics {
fileTypes {

View File

@@ -6,6 +6,7 @@ query GetImportStatistics($directoryPath: String) {
totalLocalFiles
alreadyImported
newFiles
missingFiles
percentageImported
}
}

View File

@@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Track if we've received completion events
const completionEventReceived = useRef(false);
const queueDrainedEventReceived = useRef(false);
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
// Prevents a stale "running" DB session from showing as active on hard refresh.
const sessionStartedEventReceived = useRef(false);
// Query active import session - NO POLLING, only refetch on Socket.IO events
// Query active import session - polls every 3s as a fallback when a session is
// active (e.g. tab re-opened mid-import and socket events were missed)
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
{},
{
refetchOnWindowFocus: false,
refetchInterval: false, // NO POLLING
refetchOnWindowFocus: true,
refetchInterval: (query) => {
const s = (query.state.data as any)?.getActiveImportSession;
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
? 3000
: false;
},
}
);
@@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Case 3: Check if session is actually running/active
if (status === "running" || status === "active" || status === "processing") {
// Check if there's actual progress happening
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
// Only treat as active if there's progress OR it just started
if (hasProgress && hasQueuedWork) {
// Only treat as "just started" if the started event fired in this browser session.
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
// No in-session event AND no actual progress → stale unclosed session from a previous run.
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
const likelyStale = !sessionStartedEventReceived.current
&& stats.filesProcessed === 0
&& stats.filesSucceeded === 0;
if ((hasQueuedWork || justStarted) && !likelyStale) {
return {
status: "running",
sessionId,
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
isActive: true,
};
} else {
// Session says "running" but no progress - likely stuck/stale
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
// Session says "running" but all files processed — likely a stale session
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
return {
status: "idle",
sessionId: null,
@@ -243,10 +258,11 @@ export const useImportSessionStatus = (): ImportSessionState => {
};
const handleSessionStarted = () => {
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED event received");
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED / LS_INCREMENTAL_IMPORT_STARTED event received");
// Reset completion flags when new session starts
completionEventReceived.current = false;
queueDrainedEventReceived.current = false;
sessionStartedEventReceived.current = true;
refetch();
};
@@ -259,12 +275,14 @@ export const useImportSessionStatus = (): ImportSessionState => {
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
};
}, [getSocket, refetch]);

View File

@@ -44,21 +44,23 @@ export const determineCoverFile = (data): any => {
};
// comicvine
if (!isEmpty(data.comicvine)) {
coverFile.comicvine.url = data?.comicvine?.image.small_url;
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
coverFile.comicvine.issueName = data.comicvine?.name;
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
}
// rawFileDetails
if (!isEmpty(data.rawFileDetails)) {
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
);
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
coverFile.rawFile.issueName = data.rawFileDetails.name;
} else if (!isEmpty(data.rawFileDetails)) {
coverFile.rawFile.issueName = data.rawFileDetails.name;
}
// wanted
if (!isUndefined(data.locg)) {
if (!isNil(data.locg)) {
coverFile.locg.url = data.locg.cover;
coverFile.locg.issueName = data.locg.name;
coverFile.locg.publisher = data.locg.publisher;
@@ -66,14 +68,15 @@ export const determineCoverFile = (data): any => {
const result = filter(coverFile, (item) => item.url !== "");
if (result.length > 1) {
if (result.length >= 1) {
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
if (!isUndefined(highestPriorityCoverFile)) {
return highestPriorityCoverFile;
}
} else {
return result[0];
}
// No cover URL available — return rawFile entry so the name is still shown
return coverFile.rawFile;
};
export const determineExternalMetadata = (
@@ -85,8 +88,8 @@ export const determineExternalMetadata = (
case "comicvine":
return {
coverURL:
source.comicvine?.image.small_url ||
source.comicvine.volumeInformation?.image.small_url,
source.comicvine?.image?.small_url ||
source.comicvine?.volumeInformation?.image?.small_url,
issue: source.comicvine.name,
icon: "cvlogo.svg",
};

View File

@@ -13,6 +13,8 @@ module.exports = {
scraped: "#b8edbc",
uncompressed: "#FFF3E0",
imported: "#d8dab0",
missing: "#fee2e2",
info: "#cdd9eb",
},
},
},

View File

@@ -1,132 +0,0 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function iconifyPlugin() {
const iconCache = new Map();
const collections = new Map();
function loadCollection(prefix) {
if (collections.has(prefix)) {
return collections.get(prefix);
}
try {
const collectionPath = join(__dirname, 'node_modules', '@iconify-json', prefix, 'icons.json');
const data = JSON.parse(readFileSync(collectionPath, 'utf8'));
collections.set(prefix, data);
return data;
} catch (e) {
return null;
}
}
function getIconCSS(iconData, selector) {
const { body, width, height } = iconData;
const viewBox = `0 0 ${width || 24} ${height || 24}`;
// Create SVG data URI
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">${body}</svg>`;
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
return `${selector} {
display: inline-block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-image: url("${dataUri}");
mask-image: url("${dataUri}");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}`;
}
return {
name: 'vite-plugin-iconify',
transform(code, id) {
// Only process files that might contain icon classes
if (!id.endsWith('.tsx') && !id.endsWith('.jsx') && !id.endsWith('.ts') && !id.endsWith('.js')) {
return null;
}
// Find all icon-[...] patterns
const iconPattern = /icon-\[([^\]]+)\]/g;
const matches = [...code.matchAll(iconPattern)];
if (matches.length === 0) {
return null;
}
// Extract unique icons
const icons = new Set(matches.map(m => m[1]));
// Generate CSS for each icon
for (const iconName of icons) {
if (iconCache.has(iconName)) {
continue;
}
try {
// Parse icon name (e.g., "solar--add-square-bold-duotone")
const parts = iconName.split('--');
if (parts.length !== 2) continue;
const [prefix, name] = parts;
// Load collection
const collection = loadCollection(prefix);
if (!collection || !collection.icons || !collection.icons[name]) {
continue;
}
// Get icon data
const iconData = collection.icons[name];
// Generate CSS
const selector = `.icon-\\[${iconName}\\]`;
const iconCSS = getIconCSS(iconData, selector);
iconCache.set(iconName, iconCSS);
} catch (e) {
// Silently skip failed icons
}
}
return null;
},
resolveId(id) {
if (id === '/@iconify-css') {
return id;
}
},
load(id) {
if (id === '/@iconify-css') {
const allCSS = Array.from(iconCache.values()).join('\n');
return allCSS;
}
},
transformIndexHtml() {
// Inject icon CSS into HTML
const allCSS = Array.from(iconCache.values()).join('\n');
if (allCSS) {
return [
{
tag: 'style',
attrs: { type: 'text/css' },
children: allCSS,
injectTo: 'head'
}
];
}
}
};
}