🔍 Missing files statuses in the UI
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ module.exports = {
|
|||||||
scraped: "#b8edbc",
|
scraped: "#b8edbc",
|
||||||
uncompressed: "#FFF3E0",
|
uncompressed: "#FFF3E0",
|
||||||
imported: "#d8dab0",
|
imported: "#d8dab0",
|
||||||
|
missing: "#fee2e2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user