🔍 Missing files statuses in the UI

This commit is contained in:
2026-03-09 17:10:18 -04:00
parent 8913e9cd99
commit 20336e5569
11 changed files with 381 additions and 264 deletions

View File

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

View File

@@ -63,7 +63,10 @@ export const RecentlyImported = (
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo); const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported"; const isMissingFile = isNil(rawFileDetails);
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
return ( return (
<div <div
key={idx} key={idx}

View File

@@ -1,7 +1,7 @@
import { ReactElement, useEffect, useState } from "react"; import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import axios from "axios"; import axios from "axios";
@@ -10,8 +10,8 @@ import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus"; import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
export const Import = (): ReactElement => { export const Import = (): ReactElement => {
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore( const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({ useShallow((state) => ({
importJobQueue: state.importJobQueue, importJobQueue: state.importJobQueue,
@@ -45,26 +45,53 @@ export const Import = (): ReactElement => {
const importSession = useImportSessionStatus(); const importSession = useImportSessionStatus();
const hasActiveSession = importSession.isActive; const hasActiveSession = importSession.isActive;
const wasComplete = useRef(false);
// React to importSession.isComplete rather than socket events — more reliable
// since it's derived from the actual GraphQL state, not a raw socket event.
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(() => { useEffect(() => {
const socket = getSocket("/"); const socket = getSocket("/");
const handleQueueDrained = () => refetch();
const handleCoverExtracted = () => refetch(); const handleImportCompleted = () => {
const handleSessionCompleted = () => { console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
refetch(); // Small delay to ensure backend has committed the job results
importJobQueue.setStatus("drained"); setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
}; };
const handleQueueDrained = () => {
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
return () => { return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
}; };
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]); }, [getSocket, queryClient]);
/** /**
* Handles force re-import - re-imports all files to fix indexing issues * Handles force re-import - re-imports all files to fix indexing issues
@@ -89,7 +116,6 @@ export const Import = (): ReactElement => {
disconnectSocket("/"); disconnectSocket("/");
setTimeout(() => { setTimeout(() => {
getSocket("/"); getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => { setTimeout(() => {
forceReImport(); forceReImport();
}, 500); }, 500);

View File

@@ -1,4 +1,5 @@
import { ReactElement, useEffect, useState } from "react"; import { ReactElement, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
useGetImportStatisticsQuery, useGetImportStatisticsQuery,
@@ -37,7 +38,7 @@ export const RealTimeImportStats = (): ReactElement => {
// File list for the detail panel — only fetched when there are missing files // File list for the detail panel — only fetched when there are missing files
const { data: missingComicsData } = useGetWantedComicsQuery( const { data: missingComicsData } = useGetWantedComicsQuery(
{ {
paginationOptions: { limit: 5, page: 1 }, paginationOptions: { limit: 3, page: 1 },
predicate: { "importStatus.isRawFileMissing": true }, predicate: { "importStatus.isRawFileMissing": true },
}, },
{ {
@@ -49,6 +50,18 @@ export const RealTimeImportStats = (): ReactElement => {
const missingDocs = missingComicsData?.getComicBooks?.docs ?? []; const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
const getMissingComicLabel = (comic: any): string => {
const series =
comic.canonicalMetadata?.series?.value ??
comic.inferredMetadata?.issue?.name;
const issueNum =
comic.canonicalMetadata?.issueNumber?.value ??
comic.inferredMetadata?.issue?.number;
if (series && issueNum) return `${series} #${issueNum}`;
if (series) return series;
return comic.rawFileDetails?.name ?? comic.id;
};
const importSession = useImportSessionStatus(); const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
@@ -66,13 +79,6 @@ export const RealTimeImportStats = (): ReactElement => {
const hasNewFiles = stats && stats.newFiles > 0; const hasNewFiles = stats && stats.newFiles > 0;
const missingCount = stats?.missingFiles ?? 0; const missingCount = stats?.missingFiles ?? 0;
// Mark queue drained when session completes — LS_LIBRARY_STATISTICS handles the refetch
useEffect(() => {
if (importSession.isComplete && importSession.status === "completed") {
importJobQueue.setStatus("drained");
}
}, [importSession.isComplete, importSession.status, importJobQueue]);
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion. // 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 GetImportStatistics covers: total files, imported, new files, and missing count.
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel. // Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
@@ -139,13 +145,13 @@ export const RealTimeImportStats = (): ReactElement => {
// Determine what to show in each card based on current phase // Determine what to show in each card based on current phase
const sessionStats = importSession.stats; const sessionStats = importSession.stats;
const hasSessionStats = (importSession.isActive || importSession.isComplete) && sessionStats !== null; const hasSessionStats = importSession.isActive && sessionStats !== null;
const totalFiles = stats.totalLocalFiles; const totalFiles = stats.totalLocalFiles;
const importedCount = hasSessionStats ? sessionStats!.filesSucceeded : stats.alreadyImported; const importedCount = hasSessionStats ? sessionStats!.filesSucceeded : stats.alreadyImported;
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0; const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
const showProgressBar = importSession.isActive || importSession.isComplete; const showProgressBar = importSession.isActive;
const showFailedCard = hasSessionStats && failedCount > 0; const showFailedCard = hasSessionStats && failedCount > 0;
const showMissingCard = missingCount > 0; const showMissingCard = missingCount > 0;
@@ -267,18 +273,25 @@ export const RealTimeImportStats = (): ReactElement => {
</p> </p>
{missingDocs.length > 0 && ( {missingDocs.length > 0 && (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{missingDocs.slice(0, 5).map((comic, i) => ( {missingDocs.map((comic, i) => (
<li key={i} className="text-xs text-amber-700 dark:text-amber-400 font-mono truncate"> <li key={i} className="text-xs text-amber-700 dark:text-amber-400 truncate">
{comic.rawFileDetails?.name ?? comic.id} {getMissingComicLabel(comic)} is missing
</li> </li>
))} ))}
{missingDocs.length > 5 && ( {missingCount > 3 && (
<li className="text-xs text-amber-600 dark:text-amber-500"> <li className="text-xs text-amber-600 dark:text-amber-500">
+{missingDocs.length - 5} more and {missingCount - 3} more.
</li> </li>
)} )}
</ul> </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"
>
<i className="h-4 w-4 icon-[solar--library-bold-duotone]"></i>
View all in Library
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react"; import React, { useMemo, ReactElement, useState } from "react";
import PropTypes from "prop-types"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
@@ -12,79 +11,130 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import axios from "axios"; 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. * Library page component. Displays a paginated, searchable table of all comics
* * in the collection, with an optional filter for comics with missing raw files.
* @component
* @example
* <Library />
*/ */
export const Library = (): ReactElement => { export const Library = (): ReactElement => {
// Default page state const [searchParams] = useSearchParams();
// offset: 0 const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({ const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {}, query: {},
pagination: { pagination: { size: 25, from: 0 },
size: 25,
from: offset,
},
type: "all", type: "all",
trigger: "libraryPage", trigger: "libraryPage",
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/** /** Fetches a page of issues from the search API. */
* Method that queries the Elasticsearch index "comics" for issues specified by the query const fetchIssues = async (q: SearchQuery) => {
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params. const { pagination, query, type } = q;
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
return await axios({ return await axios({
method: "POST", method: "POST",
url: "http://localhost:3000/api/search/searchIssue", url: "http://localhost:3000/api/search/searchIssue",
data: { data: { query, pagination, type },
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"] }); queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({ setSearchQuery({
query: { query: { volumeName: e.search },
volumeName: e.search, pagination: { size: 15, from: 0 },
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName", type: "volumeName",
trigger: "libraryPage", trigger: "libraryPage",
}); });
}; };
const { data, isLoading, isError, isPlaceholderData } = useQuery({ /** Advances to the next page of results. */
queryKey: ["comics", offset, searchQuery], const nextPage = (pageIndex: number, pageSize: number) => {
queryFn: () => fetchIssues(searchQuery), if (!isPlaceholderData) {
placeholderData: keepPreviousData, queryClient.invalidateQueries({ queryKey: ["comics"] });
}); setSearchQuery({
query: {},
const searchResults = data?.data; pagination: { size: 15, from: pageSize * pageIndex + 1 },
// Programmatically navigate to comic detail type: "all",
const navigate = useNavigate(); trigger: "libraryPage",
const navigateToComicDetail = (row) => { });
navigate(`/comic/details/${row.original._id}`); }
}; };
const ComicInfoXML = (value) => { /** Goes back to the previous page of results. */
return value.data ? ( 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"> <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="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"> <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> <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>
</span> </span>
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2"> <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="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"> <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> <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]} Pages: {value.data.pagecount[0]}
</span> </span>
</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="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"> <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> <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> </div>
</dl> </dl>
) : null; ) : 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-broken-bold] 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( const columns = useMemo(
() => [ () => [
{ {
header: "Comic Metadata", header: "Comic Metadata",
footer: 1,
columns: [ columns: [
{ {
header: "File Details", header: "File Details",
id: "fileDetails", id: "fileDetails",
minWidth: 250, minWidth: 250,
accessorKey: "_source", accessorKey: "_source",
cell: (info) => { cell: (info: any) => {
return <MetadataPanel data={info.getValue()} />; const source = info.getValue();
return (
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
}, },
}, },
{ {
header: "ComicInfo.xml", header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo", accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info) => cell: (info: any) =>
!isEmpty(info.getValue()) ? ( !isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
<ComicInfoXML data={info.getValue()} />
) : null,
}, },
], ],
}, },
@@ -150,19 +230,18 @@ export const Library = (): ReactElement => {
{ {
header: "Date of Import", header: "Date of Import",
accessorKey: "_source.createdAt", accessorKey: "_source.createdAt",
cell: (info) => { cell: (info: any) =>
return !isNil(info.getValue()) ? ( !isNil(info.getValue()) ? (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900"> <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")} {format(parseISO(info.getValue()), "h aaaa")}
</div> </div>
) : null; ) : null,
},
}, },
{ {
header: "Downloads", header: "Downloads",
accessorKey: "_source.acquisition", accessorKey: "_source.acquisition",
cell: (info) => ( cell: (info: any) => (
<div className="flex flex-col gap-2 ml-3 my-3"> <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">
<span className="pr-1 pt-1"> <span className="pr-1 pt-1">
@@ -172,7 +251,6 @@ export const Library = (): ReactElement => {
DC++: {info.getValue().directconnect.downloads.length} DC++: {info.getValue().directconnect.downloads.length}
</span> </span>
</span> </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 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="pr-1 pt-1"> <span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
@@ -187,130 +265,95 @@ export const Library = (): ReactElement => {
], ],
}, },
], ],
[], [missingIdSet],
); );
/** const FilterDropdown = () => (
* Pagination control that fetches the next x (pageSize) items <div className="relative">
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index <select
* @param {number} pageIndex value={activeFilter}
* @param {number} pageSize onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
* @returns void 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) => (
const nextPage = (pageIndex: number, pageSize: number) => { <option key={opt.value} value={opt.value}>
if (!isPlaceholderData) { {opt.label}
queryClient.invalidateQueries({ queryKey: ["comics"] }); </option>
setSearchQuery({ ))}
query: {}, </select>
pagination: { <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>
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>
</div> </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"}
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}
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; export default Library;

View File

@@ -10,7 +10,7 @@ interface ICardProps {
children?: PropTypes.ReactNodeLike; children?: PropTypes.ReactNodeLike;
borderColorClass?: string; borderColorClass?: string;
backgroundColor?: string; backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported"; cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: React.CSSProperties; cardContainerStyle?: React.CSSProperties;
imageStyle?: React.CSSProperties; imageStyle?: React.CSSProperties;
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
return "bg-card-uncompressed"; return "bg-card-uncompressed";
case "imported": case "imported":
return "bg-card-imported"; return "bg-card-imported";
case "missing":
return "bg-card-missing";
default: default:
return ""; return "";
} }

View File

@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash"; import { find, isUndefined } from "lodash";
interface IMetadatPanelProps { interface IMetadatPanelProps {
value: any; data: any;
children: any; value?: any;
imageStyle: any; children?: any;
titleStyle: any; imageStyle?: any;
tagsStyle: any; titleStyle?: any;
containerStyle: any; tagsStyle?: any;
containerStyle?: any;
isMissing?: boolean;
} }
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const { isMissing = false } = props;
const { const {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,
@@ -31,8 +34,11 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "rawFileDetails", name: "rawFileDetails",
content: () => ( content: () => (
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg"> <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> <dt className="flex items-center gap-2">
{isMissing && (
<i className="icon-[solar--file-remove-broken] w-4 h-4 text-red-600 shrink-0"></i>
)}
<p className="text-sm sm:text-lg">{issueName}</p> <p className="text-sm sm:text-lg">{issueName}</p>
</dt> </dt>
<dd className="text-xs sm:text-sm"> <dd className="text-xs sm:text-sm">
@@ -58,26 +64,28 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)} )}
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max"> <dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
{/* File extension */} {/* 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"> {rawFileDetails.mimeType && (
<span className="pr-1 pt-1"> <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">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i> <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>
)}
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
{/* size */} {/* 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"> {rawFileDetails.fileSize != null && (
<span className="pr-1 pt-1"> <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">
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i> <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>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span> </span>
)}
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
{/* Uncompressed version available? */} {/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && ( {rawFileDetails.archive?.uncompressed && (
@@ -177,10 +185,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const metadataPanel = find(metadataContentPanel, { const metadataPanel = find(metadataContentPanel, {
name: objectReference, name: objectReference,
}); });
return ( return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3"> <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"> <div className="w-32 sm:w-56 lg:w-52 shrink-0">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation={"cover-only"}
@@ -188,7 +196,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
imageStyle={props.imageStyle} imageStyle={props.imageStyle}
/> />
</div> </div>
<div className="flex-1">{metadataPanel.content()}</div> <div className="flex-1">{metadataPanel?.content()}</div>
</div> </div>
); );
}; };

View File

@@ -17,6 +17,7 @@ interface T2TableProps {
previousPage?(...args: unknown[]): unknown; previousPage?(...args: unknown[]): unknown;
}; };
rowClickHandler?(...args: unknown[]): unknown; rowClickHandler?(...args: unknown[]): unknown;
getRowClassName?(row: any): string;
children?: any; children?: any;
} }
@@ -27,6 +28,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
paginationHandlers: { nextPage, previousPage }, paginationHandlers: { nextPage, previousPage },
totalPages, totalPages,
rowClickHandler, rowClickHandler,
getRowClassName,
} = tableOptions; } = tableOptions;
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({ const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
@@ -140,7 +142,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
<tr <tr
key={row.id} key={row.id}
onClick={() => rowClickHandler(row)} 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" 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 ${getRowClassName ? getRowClassName(row) : ""}`}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top"> <td key={cell.id} className="px-3 py-2 align-top">

View File

@@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Track if we've received completion events // Track if we've received completion events
const completionEventReceived = useRef(false); const completionEventReceived = useRef(false);
const queueDrainedEventReceived = 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( const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
{}, {},
{ {
refetchOnWindowFocus: false, refetchOnWindowFocus: true,
refetchInterval: false, // NO POLLING 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 // Case 3: Check if session is actually running/active
if (status === "running" || status === "active" || status === "processing") { 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; const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
// Only treat as "just started" if the started event fired in this browser session.
// Only treat as active if there's progress OR it just started // Prevents a stale DB session from showing a 0% progress bar on hard refresh.
if (hasProgress && hasQueuedWork) { 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 { return {
status: "running", status: "running",
sessionId, sessionId,
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
isActive: true, isActive: true,
}; };
} else { } else {
// Session says "running" but no progress - likely stuck/stale // Session says "running" but all files processed — likely a stale session
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`); console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
return { return {
status: "idle", status: "idle",
sessionId: null, sessionId: null,
@@ -247,6 +262,7 @@ export const useImportSessionStatus = (): ImportSessionState => {
// Reset completion flags when new session starts // Reset completion flags when new session starts
completionEventReceived.current = false; completionEventReceived.current = false;
queueDrainedEventReceived.current = false; queueDrainedEventReceived.current = false;
sessionStartedEventReceived.current = true;
refetch(); refetch();
}; };

View File

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

View File

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