🔍 Missing files statuses in the UI
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/react-dom": "^2.1.7",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
|
||||
@@ -63,7 +63,10 @@ export const RecentlyImported = (
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
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 (
|
||||
<div
|
||||
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 { isEmpty } from "lodash";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
@@ -10,8 +10,8 @@ import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
export const Import = (): ReactElement => {
|
||||
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
importJobQueue: state.importJobQueue,
|
||||
@@ -45,26 +45,53 @@ export const Import = (): ReactElement => {
|
||||
|
||||
const importSession = useImportSessionStatus();
|
||||
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(() => {
|
||||
const socket = getSocket("/");
|
||||
const handleQueueDrained = () => refetch();
|
||||
const handleCoverExtracted = () => refetch();
|
||||
const handleSessionCompleted = () => {
|
||||
refetch();
|
||||
importJobQueue.setStatus("drained");
|
||||
|
||||
const handleImportCompleted = () => {
|
||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
};
|
||||
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]);
|
||||
}, [getSocket, queryClient]);
|
||||
|
||||
/**
|
||||
* Handles force re-import - re-imports all files to fix indexing issues
|
||||
@@ -89,7 +116,6 @@ export const Import = (): ReactElement => {
|
||||
disconnectSocket("/");
|
||||
setTimeout(() => {
|
||||
getSocket("/");
|
||||
setSocketReconnectTrigger(prev => prev + 1);
|
||||
setTimeout(() => {
|
||||
forceReImport();
|
||||
}, 500);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useGetImportStatisticsQuery,
|
||||
@@ -37,7 +38,7 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
// File list for the detail panel — only fetched when there are missing files
|
||||
const { data: missingComicsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 5, page: 1 },
|
||||
paginationOptions: { limit: 3, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{
|
||||
@@ -49,6 +50,18 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
|
||||
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
||||
|
||||
const getMissingComicLabel = (comic: any): string => {
|
||||
const series =
|
||||
comic.canonicalMetadata?.series?.value ??
|
||||
comic.inferredMetadata?.issue?.name;
|
||||
const issueNum =
|
||||
comic.canonicalMetadata?.issueNumber?.value ??
|
||||
comic.inferredMetadata?.issue?.number;
|
||||
if (series && issueNum) return `${series} #${issueNum}`;
|
||||
if (series) return series;
|
||||
return comic.rawFileDetails?.name ?? comic.id;
|
||||
};
|
||||
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
|
||||
@@ -66,13 +79,6 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
const hasNewFiles = stats && stats.newFiles > 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.
|
||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
||||
// 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
|
||||
const sessionStats = importSession.stats;
|
||||
const hasSessionStats = (importSession.isActive || importSession.isComplete) && sessionStats !== null;
|
||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||
|
||||
const totalFiles = stats.totalLocalFiles;
|
||||
const importedCount = hasSessionStats ? sessionStats!.filesSucceeded : stats.alreadyImported;
|
||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||
|
||||
const showProgressBar = importSession.isActive || importSession.isComplete;
|
||||
const showProgressBar = importSession.isActive;
|
||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||
const showMissingCard = missingCount > 0;
|
||||
|
||||
@@ -267,18 +273,25 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
</p>
|
||||
{missingDocs.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{missingDocs.slice(0, 5).map((comic, i) => (
|
||||
<li key={i} className="text-xs text-amber-700 dark:text-amber-400 font-mono truncate">
|
||||
{comic.rawFileDetails?.name ?? comic.id}
|
||||
{missingDocs.map((comic, i) => (
|
||||
<li key={i} className="text-xs text-amber-700 dark:text-amber-400 truncate">
|
||||
{getMissingComicLabel(comic)} is missing
|
||||
</li>
|
||||
))}
|
||||
{missingDocs.length > 5 && (
|
||||
{missingCount > 3 && (
|
||||
<li className="text-xs text-amber-600 dark:text-amber-500">
|
||||
+{missingDocs.length - 5} more
|
||||
and {missingCount - 3} more.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to="/library?filter=missingFiles"
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
||||
>
|
||||
<i className="h-4 w-4 icon-[solar--library-bold-duotone]"></i>
|
||||
View all in Library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import React, { useMemo, ReactElement, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
import T2Table from "../shared/T2Table";
|
||||
@@ -12,79 +11,130 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { format, fromUnixTime, parseISO } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useGetWantedComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type FilterOption = "all" | "missingFiles";
|
||||
|
||||
interface SearchQuery {
|
||||
query: Record<string, any>;
|
||||
pagination: { size: number; from: number };
|
||||
type: string;
|
||||
trigger: string;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: "all", label: "All Comics" },
|
||||
{ value: "missingFiles", label: "Missing Files" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* <Library />
|
||||
* Library page component. Displays a paginated, searchable table of all comics
|
||||
* in the collection, with an optional filter for comics with missing raw files.
|
||||
*/
|
||||
export const Library = (): ReactElement => {
|
||||
// Default page state
|
||||
// offset: 0
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState({
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: offset,
|
||||
},
|
||||
pagination: { size: 25, from: 0 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||
*/
|
||||
const fetchIssues = async (searchQuery) => {
|
||||
const { pagination, query, type } = searchQuery;
|
||||
/** Fetches a page of issues from the search API. */
|
||||
const fetchIssues = async (q: SearchQuery) => {
|
||||
const { pagination, query, type } = q;
|
||||
return await axios({
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/api/search/searchIssue",
|
||||
data: {
|
||||
query,
|
||||
pagination,
|
||||
type,
|
||||
},
|
||||
data: { query, pagination, type },
|
||||
});
|
||||
};
|
||||
|
||||
const searchIssues = (e) => {
|
||||
const { data, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: activeFilter === "all",
|
||||
});
|
||||
|
||||
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 25, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "missingFiles" },
|
||||
);
|
||||
|
||||
const { data: missingIdsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 1000, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "all" },
|
||||
);
|
||||
|
||||
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
||||
const missingIdSet = useMemo(
|
||||
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
||||
[missingIdsData],
|
||||
);
|
||||
|
||||
const searchResults = data?.data;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
||||
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
||||
|
||||
/** Triggers a search by volume name and resets pagination. */
|
||||
const searchIssues = (e: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {
|
||||
volumeName: e.search,
|
||||
},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: 0,
|
||||
},
|
||||
query: { volumeName: e.search },
|
||||
pagination: { size: 15, from: 0 },
|
||||
type: "volumeName",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", offset, searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const searchResults = data?.data;
|
||||
// Programmatically navigate to comic detail
|
||||
const navigate = useNavigate();
|
||||
const navigateToComicDetail = (row) => {
|
||||
navigate(`/comic/details/${row.original._id}`);
|
||||
/** Advances to the next page of results. */
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
/** Goes back to the previous page of results. */
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: { size: 15, from },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value: any) =>
|
||||
value.data ? (
|
||||
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
|
||||
{/* Series Name */}
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
@@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
||||
{/* Pages */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||
@@ -103,7 +152,6 @@ export const Library = (): ReactElement => {
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||
@@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
|
||||
</div>
|
||||
</dl>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const missingFilesColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Missing Files",
|
||||
columns: [
|
||||
{
|
||||
header: "Status",
|
||||
id: "missingStatus",
|
||||
cell: () => (
|
||||
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
|
||||
<i className="icon-[solar--file-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(
|
||||
() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
footer: 1,
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 250,
|
||||
accessorKey: "_source",
|
||||
cell: (info) => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
cell: (info: any) => {
|
||||
const source = info.getValue();
|
||||
return (
|
||||
<MetadataPanel
|
||||
data={source}
|
||||
isMissing={missingIdSet.has(info.row.original._id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
cell: (info) =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : null,
|
||||
cell: (info: any) =>
|
||||
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -150,19 +230,18 @@ export const Library = (): ReactElement => {
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
cell: (info: any) =>
|
||||
!isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
cell: (info: any) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
@@ -172,7 +251,6 @@ export const Library = (): ReactElement => {
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</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="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
@@ -187,130 +265,95 @@ export const Library = (): ReactElement => {
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
[missingIdSet],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the next x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
*
|
||||
**/
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(pageSize * pageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the previous x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
**/
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(from);
|
||||
};
|
||||
|
||||
// ImportStatus.propTypes = {
|
||||
// value: PropTypes.bool.isRequired,
|
||||
// };
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!isUndefined(searchResults?.hits) ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div>
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
paginationHandlers={{
|
||||
nextPage,
|
||||
previousPage,
|
||||
}}
|
||||
>
|
||||
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||
</T2Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no
|
||||
indices. Try importing a few comics into the library and come
|
||||
back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
|
||||
{!isUndefined(searchResults?.data?.meta?.body) ? (
|
||||
<p>
|
||||
{JSON.stringify(
|
||||
searchResults?.data.meta.body.error.root_cause,
|
||||
null,
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
const FilterDropdown = () => (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={activeFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
||||
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isMissingFilter = activeFilter === "missingFiles";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMissingFilter ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{isMissingLoading ? (
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
) : (
|
||||
<T2Table
|
||||
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
|
||||
columns={missingFilesColumns}
|
||||
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
|
||||
rowClickHandler={navigateToMissingComicDetail}
|
||||
getRowClassName={() => "bg-card-missing/40"}
|
||||
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;
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ICardProps {
|
||||
children?: PropTypes.ReactNodeLike;
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: React.CSSProperties;
|
||||
imageStyle?: React.CSSProperties;
|
||||
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
|
||||
return "bg-card-uncompressed";
|
||||
case "imported":
|
||||
return "bg-card-imported";
|
||||
case "missing":
|
||||
return "bg-card-missing";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { find, isUndefined } from "lodash";
|
||||
|
||||
interface IMetadatPanelProps {
|
||||
value: any;
|
||||
children: any;
|
||||
imageStyle: any;
|
||||
titleStyle: any;
|
||||
tagsStyle: any;
|
||||
containerStyle: any;
|
||||
data: any;
|
||||
value?: any;
|
||||
children?: any;
|
||||
imageStyle?: any;
|
||||
titleStyle?: any;
|
||||
tagsStyle?: any;
|
||||
containerStyle?: any;
|
||||
isMissing?: boolean;
|
||||
}
|
||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const { isMissing = false } = props;
|
||||
const {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
@@ -31,8 +34,11 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
{
|
||||
name: "rawFileDetails",
|
||||
content: () => (
|
||||
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
|
||||
<dt>
|
||||
<dl className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}>
|
||||
<dt className="flex items-center gap-2">
|
||||
{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>
|
||||
</dt>
|
||||
<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">
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
{rawFileDetails.mimeType && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
{rawFileDetails.fileSize != null && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--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 className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncompressed version available? */}
|
||||
{rawFileDetails.archive?.uncompressed && (
|
||||
@@ -177,10 +185,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const metadataPanel = find(metadataContentPanel, {
|
||||
name: objectReference,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
||||
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
||||
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
@@ -188,7 +196,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">{metadataPanel.content()}</div>
|
||||
<div className="flex-1">{metadataPanel?.content()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ interface T2TableProps {
|
||||
previousPage?(...args: unknown[]): unknown;
|
||||
};
|
||||
rowClickHandler?(...args: unknown[]): unknown;
|
||||
getRowClassName?(row: any): string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
paginationHandlers: { nextPage, previousPage },
|
||||
totalPages,
|
||||
rowClickHandler,
|
||||
getRowClassName,
|
||||
} = tableOptions;
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
@@ -140,7 +142,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => rowClickHandler(row)}
|
||||
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer"
|
||||
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) => (
|
||||
<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
|
||||
const completionEventReceived = useRef(false);
|
||||
const queueDrainedEventReceived = useRef(false);
|
||||
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
|
||||
// Prevents a stale "running" DB session from showing as active on hard refresh.
|
||||
const sessionStartedEventReceived = useRef(false);
|
||||
|
||||
// Query active import session - NO POLLING, only refetch on Socket.IO events
|
||||
// Query active import session - polls every 3s as a fallback when a session is
|
||||
// active (e.g. tab re-opened mid-import and socket events were missed)
|
||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false, // NO POLLING
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (query) => {
|
||||
const s = (query.state.data as any)?.getActiveImportSession;
|
||||
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
|
||||
// Case 3: Check if session is actually running/active
|
||||
if (status === "running" || status === "active" || status === "processing") {
|
||||
// Check if there's actual progress happening
|
||||
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
|
||||
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
||||
|
||||
// Only treat as active if there's progress OR it just started
|
||||
if (hasProgress && hasQueuedWork) {
|
||||
// Only treat as "just started" if the started event fired in this browser session.
|
||||
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
|
||||
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
||||
|
||||
// No in-session event AND no actual progress → stale unclosed session from a previous run.
|
||||
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
|
||||
const likelyStale = !sessionStartedEventReceived.current
|
||||
&& stats.filesProcessed === 0
|
||||
&& stats.filesSucceeded === 0;
|
||||
|
||||
if ((hasQueuedWork || justStarted) && !likelyStale) {
|
||||
return {
|
||||
status: "running",
|
||||
sessionId,
|
||||
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
isActive: true,
|
||||
};
|
||||
} else {
|
||||
// Session says "running" but no progress - likely stuck/stale
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
// Session says "running" but all files processed — likely a stale session
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
@@ -247,6 +262,7 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
// Reset completion flags when new session starts
|
||||
completionEventReceived.current = false;
|
||||
queueDrainedEventReceived.current = false;
|
||||
sessionStartedEventReceived.current = true;
|
||||
refetch();
|
||||
};
|
||||
|
||||
|
||||
@@ -44,21 +44,23 @@ export const determineCoverFile = (data): any => {
|
||||
};
|
||||
// comicvine
|
||||
if (!isEmpty(data.comicvine)) {
|
||||
coverFile.comicvine.url = data?.comicvine?.image.small_url;
|
||||
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
|
||||
coverFile.comicvine.issueName = data.comicvine?.name;
|
||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||
}
|
||||
// rawFileDetails
|
||||
if (!isEmpty(data.rawFileDetails)) {
|
||||
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||
);
|
||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||
} else if (!isEmpty(data.rawFileDetails)) {
|
||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||
}
|
||||
// wanted
|
||||
|
||||
if (!isUndefined(data.locg)) {
|
||||
if (!isNil(data.locg)) {
|
||||
coverFile.locg.url = data.locg.cover;
|
||||
coverFile.locg.issueName = data.locg.name;
|
||||
coverFile.locg.publisher = data.locg.publisher;
|
||||
@@ -66,14 +68,15 @@ export const determineCoverFile = (data): any => {
|
||||
|
||||
const result = filter(coverFile, (item) => item.url !== "");
|
||||
|
||||
if (result.length > 1) {
|
||||
if (result.length >= 1) {
|
||||
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
||||
if (!isUndefined(highestPriorityCoverFile)) {
|
||||
return highestPriorityCoverFile;
|
||||
}
|
||||
} else {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// No cover URL available — return rawFile entry so the name is still shown
|
||||
return coverFile.rawFile;
|
||||
};
|
||||
|
||||
export const determineExternalMetadata = (
|
||||
@@ -85,8 +88,8 @@ export const determineExternalMetadata = (
|
||||
case "comicvine":
|
||||
return {
|
||||
coverURL:
|
||||
source.comicvine?.image.small_url ||
|
||||
source.comicvine.volumeInformation?.image.small_url,
|
||||
source.comicvine?.image?.small_url ||
|
||||
source.comicvine?.volumeInformation?.image?.small_url,
|
||||
issue: source.comicvine.name,
|
||||
icon: "cvlogo.svg",
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ module.exports = {
|
||||
scraped: "#b8edbc",
|
||||
uncompressed: "#FFF3E0",
|
||||
imported: "#d8dab0",
|
||||
missing: "#fee2e2",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user