Compare commits
11 Commits
dependabot
...
0e8f63101c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e8f63101c | |||
| 4e2cad790b | |||
| ba1b5bb965 | |||
| 8546641152 | |||
| 867935be39 | |||
| d506cf8ba8 | |||
| 71d7034d01 | |||
| a217d447fa | |||
| 20336e5569 | |||
| 8913e9cd99 | |||
| c392333170 |
@@ -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",
|
||||
|
||||
@@ -67,13 +67,8 @@ type ComicDetailProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Component for displaying the metadata for a comic in greater detail.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* return (
|
||||
* <ComicDetail/>
|
||||
* )
|
||||
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
||||
* for metadata, archive operations, and acquisition.
|
||||
*/
|
||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const {
|
||||
@@ -84,7 +79,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||
acquisition,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
userSettings,
|
||||
queryClient,
|
||||
@@ -94,24 +88,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [modalIsOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
||||
|
||||
// Modal handlers (currently unused but kept for future use)
|
||||
const openModal = useCallback((filePath: string) => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const afterOpenModal = useCallback((things: any) => {
|
||||
// Modal opened callback
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||
@@ -224,10 +204,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
rawFileDetails: rawFileDetails,
|
||||
inferredMetadata: inferredMetadata,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
createdAt,
|
||||
}}
|
||||
>
|
||||
{/* action dropdown */}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props;
|
||||
interface ComicVineMatchPanelProps {
|
||||
props: {
|
||||
comicObjectId: string;
|
||||
comicVineMatches: any[];
|
||||
queryClient?: any;
|
||||
onMatchApplied?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** Displays ComicVine search results or a status message while searching. */
|
||||
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
|
||||
const { comicvine } = useStore(
|
||||
useShallow((state) => ({
|
||||
comicvine: state.comicvine,
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import React, { ReactElement } from "react";
|
||||
import { Form, Field, FieldRenderProps } from "react-final-form";
|
||||
import arrayMutators from "final-form-arrays";
|
||||
import { FieldArray } from "react-final-form-arrays";
|
||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
export const EditMetadataPanel = (props): ReactElement => {
|
||||
const validate = async () => {};
|
||||
interface EditMetadataPanelProps {
|
||||
data: {
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Sliding panel form for manually editing comic metadata fields. */
|
||||
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
|
||||
const onSubmit = async () => {};
|
||||
|
||||
const { data } = props;
|
||||
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<AsyncSelectPaginate
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
// const rawFileDetails = useSelector(
|
||||
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||
// );
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
mutators={{
|
||||
...arrayMutators,
|
||||
}}
|
||||
mutators={{ ...arrayMutators }}
|
||||
render={({
|
||||
handleSubmit,
|
||||
form: {
|
||||
mutators: { push, pop },
|
||||
}, // injected from final-form-arrays above
|
||||
pristine,
|
||||
form,
|
||||
submitting,
|
||||
values,
|
||||
},
|
||||
}) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Issue Name */}
|
||||
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
<p className="text-xs">Do not enter the first zero</p>
|
||||
</div>
|
||||
<div>
|
||||
{/* year */}
|
||||
<div className="text-sm">Issue Year</div>
|
||||
<Field
|
||||
name="issue_year"
|
||||
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* page count */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mt-2">
|
||||
<label className="text-sm">Description</label>
|
||||
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label">
|
||||
@@ -153,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* Publisher */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -224,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* team credits */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -302,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
))
|
||||
}
|
||||
</FieldArray>
|
||||
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
|
||||
interface MatchResultProps {
|
||||
matchData: any;
|
||||
@@ -31,7 +32,7 @@ export const MatchResult = (props: MatchResultProps) => {
|
||||
// Invalidate and refetch the comic book metadata
|
||||
if (props.queryClient) {
|
||||
await props.queryClient.invalidateQueries({
|
||||
queryKey: ["comicBookMetadata", comicObjectId],
|
||||
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import {
|
||||
RawFileDetails as RawFileDetailsType,
|
||||
InferredMetadata,
|
||||
} from "../../graphql/generated";
|
||||
|
||||
type RawFileDetailsProps = {
|
||||
data?: {
|
||||
rawFileDetails?: RawFileDetailsType;
|
||||
inferredMetadata?: {
|
||||
issue?: {
|
||||
year?: string;
|
||||
name?: string;
|
||||
number?: number;
|
||||
subtitle?: string;
|
||||
};
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
inferredMetadata?: InferredMetadata;
|
||||
createdAt?: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data || {};
|
||||
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-2xl ml-5">
|
||||
@@ -97,10 +92,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{created_at ? (
|
||||
{createdAt && isValid(parseISO(createdAt)) ? (
|
||||
<>
|
||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(created_at), "h aaaa")}
|
||||
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(createdAt), "h aaaa")}
|
||||
</>
|
||||
) : "N/A"}
|
||||
</dd>
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { isEmpty, isUndefined, map } from "lodash";
|
||||
import Header from "../shared/Header";
|
||||
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
|
||||
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
||||
|
||||
type LibraryStatisticsProps = {
|
||||
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
|
||||
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
||||
comicDirectorySize: DirectorySize;
|
||||
comicsMissingFiles: number;
|
||||
};
|
||||
|
||||
export const LibraryStatistics = (
|
||||
props: LibraryStatisticsProps,
|
||||
): ReactElement => {
|
||||
const { stats } = props;
|
||||
/** Props for {@link LibraryStatistics}. */
|
||||
interface LibraryStatisticsProps {
|
||||
stats: Stats | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a snapshot of library metrics: total comic files, tagging coverage,
|
||||
* file-type breakdown, and the publisher with the most issues.
|
||||
*
|
||||
* Returns `null` when `stats` is absent or the statistics array is empty.
|
||||
*/
|
||||
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
|
||||
if (!stats || !stats.totalDocuments) return null;
|
||||
|
||||
const facet = stats.statistics?.[0];
|
||||
if (!facet) return null;
|
||||
|
||||
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
|
||||
const topPublisher = publisherWithMostComicsInLibrary?.[0];
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<Header
|
||||
headerContent="Your Library In Numbers"
|
||||
subHeaderContent={
|
||||
<span className="text-md">A brief snapshot of your library.</span>
|
||||
}
|
||||
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-row gap-5">
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{props.stats.totalDocuments} files
|
||||
</dd>
|
||||
{props.stats.comicDirectorySize?.fileCount && (
|
||||
<dd>
|
||||
<span className="text-2xl text-green-600">
|
||||
{props.stats.comicDirectorySize.fileCount} comic files
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
{/* comicinfo and comicvine tagged issues */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics?.[0]?.issues) && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics?.[0]?.issues?.length || 0}
|
||||
</span>{" "}
|
||||
tagged with ComicVine
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0}
|
||||
</span>{" "}
|
||||
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||
with ComicInfo.xml
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
|
||||
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
|
||||
>
|
||||
{fileType.data.length} {fileType.id}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file types */}
|
||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||
{/* publisher with most issues */}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(
|
||||
props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0],
|
||||
) && (
|
||||
<>
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics?.[0]
|
||||
?.publisherWithMostComicsInLibrary?.[0]?.id
|
||||
}
|
||||
</span>
|
||||
{" has the most issues "}
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics?.[0]
|
||||
?.publisherWithMostComicsInLibrary?.[0]?.count
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row gap-5">
|
||||
{/* Total records in database */}
|
||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">In database</dt>
|
||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||
{stats.totalDocuments} comics
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Missing files */}
|
||||
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
|
||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||
{stats.comicsMissingFiles}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Disk space consumed */}
|
||||
{stats.comicDirectorySize.totalSizeInGB != null && (
|
||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
|
||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tagging coverage */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{issues && issues.length > 0 && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||
<span className="text-xl text-gray-700">{issues.length}</span>
|
||||
tagged with ComicVine
|
||||
</div>
|
||||
)}
|
||||
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
|
||||
with ComicInfo.xml
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File-type breakdown */}
|
||||
{fileTypes && fileTypes.length > 0 && (
|
||||
<div>
|
||||
{fileTypes.map((ft) => (
|
||||
<span
|
||||
key={ft.id}
|
||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
|
||||
>
|
||||
{ft.data.length} {ft.id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publisher with most issues */}
|
||||
{topPublisher && (
|
||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
|
||||
<span>{topPublisher.id}</span>
|
||||
{" has the most issues "}
|
||||
<span>{topPublisher.count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export const RecentlyImported = (
|
||||
sourcedMetadata,
|
||||
canonicalMetadata,
|
||||
inferredMetadata,
|
||||
importStatus,
|
||||
} = comic;
|
||||
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
@@ -63,7 +64,10 @@ export const RecentlyImported = (
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||
const isMissingFile = importStatus?.isRawFileMissing === true;
|
||||
const cardState = isMissingFile
|
||||
? "missing"
|
||||
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
@@ -127,12 +131,6 @@ export const RecentlyImported = (
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Raw file presence */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,11 @@ type VolumeGroupsProps = {
|
||||
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
||||
};
|
||||
|
||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
|
||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
|
||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateToVolumes = (row: any) => {
|
||||
navigate(`/volumes/all`);
|
||||
|
||||
@@ -15,7 +15,9 @@ type WantedComicsListProps = {
|
||||
|
||||
export const WantedComicsList = ({
|
||||
comics,
|
||||
}: WantedComicsListProps): ReactElement => {
|
||||
}: WantedComicsListProps): ReactElement | null => {
|
||||
if (!comics || comics.length === 0) return null;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// embla carousel
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
import {
|
||||
useGetJobResultStatisticsQuery,
|
||||
useGetImportStatisticsQuery,
|
||||
useStartIncrementalImportMutation
|
||||
} from "../../graphql/generated";
|
||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
interface ImportProps {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import component for adding comics to the ThreeTwo library.
|
||||
* Provides preview statistics, smart import, and queue management.
|
||||
*/
|
||||
export const Import = (props: ImportProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
|
||||
export const Import = (): ReactElement => {
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
importJobQueue: state.importJobQueue,
|
||||
@@ -33,30 +20,6 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
})),
|
||||
);
|
||||
|
||||
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.startIncrementalImport.success) {
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Failed to start import:", error);
|
||||
setImportError(error?.message || "Failed to start import. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: initiateImport } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
return await axios.request({
|
||||
url: `http://localhost:3000/api/library/newImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Force re-import mutation - re-imports all files regardless of import status
|
||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -78,101 +41,57 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||
|
||||
// Get import statistics to determine if Start Import button should be shown
|
||||
const { data: importStats } = useGetImportStatisticsQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||
|
||||
// Use custom hook for definitive import session status tracking
|
||||
// NO POLLING - relies on Socket.IO events only
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const hasActiveSession = importSession.isActive;
|
||||
const wasComplete = useRef(false);
|
||||
|
||||
// Determine if we should show the Start Import button
|
||||
const hasNewFiles = importStats?.getImportStatistics?.success &&
|
||||
importStats.getImportStatistics.stats &&
|
||||
importStats.getImportStatistics.stats.newFiles > 0;
|
||||
// React to importSession.isComplete rather than socket events — more reliable
|
||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
||||
useEffect(() => {
|
||||
if (importSession.isComplete && !wasComplete.current) {
|
||||
wasComplete.current = true;
|
||||
// Small delay so the backend has time to commit job result stats
|
||||
setTimeout(() => {
|
||||
// Invalidate the cache to force a fresh fetch of job result statistics
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
refetch();
|
||||
}, 1500);
|
||||
importJobQueue.setStatus("drained");
|
||||
} else if (!importSession.isComplete) {
|
||||
wasComplete.current = false;
|
||||
}
|
||||
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
||||
|
||||
// Listen to socket events to update Past Imports table in real-time
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
const handleQueueDrained = () => refetch();
|
||||
const handleCoverExtracted = () => refetch();
|
||||
|
||||
const handleSessionStarted = () => {
|
||||
importJobQueue.setStatus("running");
|
||||
|
||||
const handleImportCompleted = () => {
|
||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSessionCompleted = () => {
|
||||
refetch();
|
||||
importJobQueue.setStatus("drained");
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
||||
// Small delay to ensure backend has committed the job results
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
};
|
||||
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]);
|
||||
|
||||
/**
|
||||
* Toggles import queue pause/resume state
|
||||
*/
|
||||
const toggleQueue = (queueAction: string, queueStatus: string) => {
|
||||
const socket = getSocket("/");
|
||||
socket.emit(
|
||||
"call",
|
||||
"socket.setQueueStatus",
|
||||
{
|
||||
queueAction,
|
||||
queueStatus,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts smart import with race condition prevention
|
||||
*/
|
||||
const handleStartSmartImport = async () => {
|
||||
// Clear any previous errors
|
||||
setImportError(null);
|
||||
|
||||
// Check for active session before starting using definitive status
|
||||
if (hasActiveSession) {
|
||||
setImportError(
|
||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (importJobQueue.status === "drained") {
|
||||
localStorage.removeItem("sessionId");
|
||||
disconnectSocket("/");
|
||||
setTimeout(() => {
|
||||
getSocket("/");
|
||||
setSocketReconnectTrigger(prev => prev + 1);
|
||||
setTimeout(() => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
startIncrementalImport({ sessionId });
|
||||
}, 500);
|
||||
}, 100);
|
||||
} else {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
startIncrementalImport({ sessionId });
|
||||
}
|
||||
};
|
||||
}, [getSocket, queryClient]);
|
||||
|
||||
/**
|
||||
* Handles force re-import - re-imports all files to fix indexing issues
|
||||
@@ -197,7 +116,6 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
disconnectSocket("/");
|
||||
setTimeout(() => {
|
||||
getSocket("/");
|
||||
setSocketReconnectTrigger(prev => prev + 1);
|
||||
setTimeout(() => {
|
||||
forceReImport();
|
||||
}, 500);
|
||||
@@ -208,54 +126,6 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders pause/resume controls based on queue status
|
||||
*/
|
||||
const renderQueueControls = (status: string): ReactElement | null => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
toggleQueue("pause", "paused");
|
||||
importJobQueue.setStatus("paused");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Pause</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "paused":
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
toggleQueue("resume", "running");
|
||||
importJobQueue.setStatus("running");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Resume</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "drained":
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
@@ -327,53 +197,10 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Session Warning */}
|
||||
{hasActiveSession && !hasNewFiles && (
|
||||
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 text-yellow-600 dark:text-yellow-400 mt-0.5">
|
||||
<i className="h-6 w-6 icon-[solar--info-circle-bold]"></i>
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Import In Progress
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
|
||||
An import session is currently active. New imports cannot be started until it completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Action Buttons */}
|
||||
<div className="my-6 max-w-screen-lg flex flex-col sm:flex-row gap-3">
|
||||
{/* Start Smart Import Button - shown when there are new files, no active session, and no import is running */}
|
||||
{hasNewFiles &&
|
||||
!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleStartSmartImport}
|
||||
disabled={isStartingImport || hasActiveSession}
|
||||
>
|
||||
<span className="text-md font-medium">
|
||||
{isStartingImport
|
||||
? "Starting Import..."
|
||||
: importStats?.getImportStatistics?.stats?.alreadyImported === 0
|
||||
? `Start Import (${importStats?.getImportStatistics?.stats?.newFiles} files)`
|
||||
: `Start Incremental Import (${importStats?.getImportStatistics?.stats?.newFiles} new files)`
|
||||
}
|
||||
</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Force Re-Import Button - always shown when no import is running */}
|
||||
{!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||
{/* Force Re-Import Button - always shown when no import is running */}
|
||||
{!hasActiveSession &&
|
||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleForceReImport}
|
||||
@@ -387,8 +214,8 @@ export const Import = (props: ImportProps): ReactElement => {
|
||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||
|
||||
|
||||
@@ -1,93 +1,167 @@
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useGetImportStatisticsQuery,
|
||||
useStartIncrementalImportMutation
|
||||
useGetWantedComicsQuery,
|
||||
useStartIncrementalImportMutation,
|
||||
} from "../../graphql/generated";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
/**
|
||||
* Import statistics with card-based layout and progress bar
|
||||
* Updates in real-time via the useImportSessionStatus hook
|
||||
* Import statistics with card-based layout and progress bar.
|
||||
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
||||
* Also surfaces missing files detected by the file watcher.
|
||||
*/
|
||||
export const RealTimeImportStats = (): ReactElement => {
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
||||
const [socketImport, setSocketImport] = useState<{
|
||||
active: boolean;
|
||||
completed: number;
|
||||
total: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||
useShallow((state) => ({
|
||||
getSocket: state.getSocket,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
importJobQueue: state.importJobQueue,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// Get filesystem statistics (new files vs already imported)
|
||||
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
|
||||
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
||||
{},
|
||||
{ refetchOnWindowFocus: false, refetchInterval: false }
|
||||
{ refetchOnWindowFocus: false, refetchInterval: false },
|
||||
);
|
||||
|
||||
// Get definitive import session status (handles Socket.IO events internally)
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.startIncrementalImport.success) {
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Failed to start import:", error);
|
||||
setImportError(error?.message || "Failed to start import. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const stats = importStats?.getImportStatistics?.stats;
|
||||
|
||||
// File list for the detail panel — only fetched when there are missing files
|
||||
const { data: missingComicsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 3, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false,
|
||||
enabled: (stats?.missingFiles ?? 0) > 0,
|
||||
},
|
||||
);
|
||||
|
||||
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
||||
|
||||
const getMissingComicLabel = (comic: any): string => {
|
||||
const series =
|
||||
comic.canonicalMetadata?.series?.value ??
|
||||
comic.inferredMetadata?.issue?.name;
|
||||
const issueNum =
|
||||
comic.canonicalMetadata?.issueNumber?.value ??
|
||||
comic.inferredMetadata?.issue?.number;
|
||||
if (series && issueNum) return `${series} #${issueNum}`;
|
||||
if (series) return series;
|
||||
return comic.rawFileDetails?.name ?? comic.id;
|
||||
};
|
||||
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
||||
useStartIncrementalImportMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.startIncrementalImport.success) {
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setImportError(
|
||||
error?.message || "Failed to start import. Please try again.",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const hasNewFiles = stats && stats.newFiles > 0;
|
||||
const missingCount = stats?.missingFiles ?? 0;
|
||||
|
||||
// Refetch filesystem stats when import completes
|
||||
useEffect(() => {
|
||||
if (importSession.isComplete && importSession.status === "completed") {
|
||||
console.log("[RealTimeImportStats] Import completed, refetching filesystem stats");
|
||||
refetchStats();
|
||||
importJobQueue.setStatus("drained");
|
||||
}
|
||||
}, [importSession.isComplete, importSession.status, refetchStats, importJobQueue]);
|
||||
|
||||
// Listen to filesystem change events to refetch stats
|
||||
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
||||
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleFilesystemChange = () => {
|
||||
refetchStats();
|
||||
const handleStatsChange = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
||||
};
|
||||
|
||||
// File system changes that affect import statistics
|
||||
socket.on("LS_FILE_ADDED", handleFilesystemChange);
|
||||
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
|
||||
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
|
||||
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
||||
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
||||
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
||||
const handleFileDetected = (payload: { filePath: string }) => {
|
||||
handleStatsChange();
|
||||
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
||||
setDetectedFile(name);
|
||||
setTimeout(() => setDetectedFile(null), 5000);
|
||||
};
|
||||
|
||||
const handleImportStarted = () => {
|
||||
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
||||
};
|
||||
|
||||
const handleCoverExtracted = (payload: {
|
||||
completedJobCount: number;
|
||||
totalJobCount: number;
|
||||
importResult: unknown;
|
||||
}) => {
|
||||
setSocketImport((prev) => ({
|
||||
active: true,
|
||||
completed: payload.completedJobCount,
|
||||
total: payload.totalJobCount,
|
||||
failed: prev?.failed ?? 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCoverExtractionFailed = (payload: {
|
||||
failedJobCount: number;
|
||||
importResult: unknown;
|
||||
}) => {
|
||||
setSocketImport((prev) =>
|
||||
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
||||
);
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
||||
handleStatsChange();
|
||||
};
|
||||
|
||||
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
||||
socket.on("LS_FILES_MISSING", handleStatsChange);
|
||||
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
|
||||
return () => {
|
||||
socket.off("LS_FILE_ADDED", handleFilesystemChange);
|
||||
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
|
||||
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
|
||||
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
||||
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
||||
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
||||
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
||||
socket.off("LS_FILES_MISSING", handleStatsChange);
|
||||
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
};
|
||||
}, [getSocket, refetchStats]);
|
||||
}, [getSocket, queryClient]);
|
||||
|
||||
const handleStartImport = async () => {
|
||||
setImportError(null);
|
||||
|
||||
// Check if import is already active using definitive status
|
||||
if (importSession.isActive) {
|
||||
setImportError(
|
||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -112,24 +186,26 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
||||
}
|
||||
|
||||
// Determine button text based on whether there are already imported files
|
||||
const isFirstImport = stats.alreadyImported === 0;
|
||||
const buttonText = isFirstImport
|
||||
? `Start Import (${stats.newFiles} files)`
|
||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||
|
||||
// Calculate display statistics
|
||||
const displayStats = importSession.isActive && importSession.stats
|
||||
? {
|
||||
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
|
||||
filesQueued: importSession.stats.filesQueued,
|
||||
filesSucceeded: importSession.stats.filesSucceeded,
|
||||
}
|
||||
: {
|
||||
totalFiles: stats.totalLocalFiles,
|
||||
filesQueued: stats.newFiles,
|
||||
filesSucceeded: stats.alreadyImported,
|
||||
};
|
||||
// Determine what to show in each card based on current phase
|
||||
const sessionStats = importSession.stats;
|
||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||
|
||||
const totalFiles = stats.totalLocalFiles;
|
||||
const importedCount = stats.alreadyImported;
|
||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||
|
||||
const showProgressBar = socketImport !== null;
|
||||
const socketProgressPct =
|
||||
socketImport && socketImport.total > 0
|
||||
? Math.round((socketImport.completed / socketImport.total) * 100)
|
||||
: 0;
|
||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||
const showMissingCard = missingCount > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -141,89 +217,161 @@ export const RealTimeImportStats = (): ReactElement => {
|
||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">Import Error</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{importError}</p>
|
||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
||||
Import Error
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||
{importError}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportError(null)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||
>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||
</span>
|
||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Button - only show when there are new files and no active import */}
|
||||
{/* File detected toast */}
|
||||
{detectedFile && (
|
||||
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
||||
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
|
||||
New file detected: {detectedFile}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Import button — only when idle with new files */}
|
||||
{hasNewFiles && !importSession.isActive && (
|
||||
<button
|
||||
onClick={handleStartImport}
|
||||
disabled={isStartingImport}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
</span>
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Active Import Progress Bar */}
|
||||
{importSession.isActive && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
|
||||
{/* Progress bar — shown while importing and once complete */}
|
||||
{showProgressBar && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{socketImport!.active
|
||||
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
||||
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(importSession.progress)}%
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{socketProgressPct}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
||||
style={{ width: `${importSession.progress}%` }}
|
||||
style={{ width: `${socketProgressPct}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
|
||||
{socketImport!.active && (
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Files Detected Card */}
|
||||
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
|
||||
<div className="text-4xl font-bold text-white mb-2">
|
||||
{displayStats.totalFiles}
|
||||
</div>
|
||||
<div className="text-sm text-gray-200 font-medium">
|
||||
files detected
|
||||
</div>
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{/* Total files */}
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{ backgroundColor: "#6b7280" }}
|
||||
>
|
||||
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
||||
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
||||
</div>
|
||||
|
||||
{/* To Import Card */}
|
||||
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
|
||||
<div className="text-4xl font-bold text-white mb-2">
|
||||
{displayStats.filesQueued}
|
||||
</div>
|
||||
<div className="text-sm text-gray-100 font-medium">
|
||||
to import
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Already Imported Card */}
|
||||
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#d8dab2' }}>
|
||||
{/* Imported */}
|
||||
<div
|
||||
className="rounded-lg p-6 text-center"
|
||||
style={{ backgroundColor: "#d8dab2" }}
|
||||
>
|
||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{displayStats.filesSucceeded}
|
||||
{importedCount}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 font-medium">
|
||||
already imported
|
||||
{importSession.isActive ? "imported so far" : "imported in database"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed — only shown after a session with failures */}
|
||||
{showFailedCard && (
|
||||
<div className="rounded-lg p-6 text-center bg-red-500">
|
||||
<div className="text-4xl font-bold text-white mb-2">
|
||||
{failedCount}
|
||||
</div>
|
||||
<div className="text-sm text-red-100 font-medium">failed</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing files — shown when watcher detects moved/deleted files */}
|
||||
{showMissingCard && (
|
||||
<div className="rounded-lg p-6 text-center bg-card-missing">
|
||||
<div className="text-4xl font-bold text-slate-700 mb-2">
|
||||
{missingCount}
|
||||
</div>
|
||||
<div className="text-sm text-slate-800 font-medium">missing</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Missing files detail panel */}
|
||||
{showMissingCard && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||
{missingCount} {missingCount === 1 ? "file" : "files"} missing
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
These files were previously imported but can no longer be found
|
||||
on disk. Move them back to restore access.
|
||||
</p>
|
||||
{missingDocs.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{missingDocs.map((comic, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-xs text-amber-700 dark:text-amber-400 truncate"
|
||||
>
|
||||
{getMissingComicLabel(comic)} is missing
|
||||
</li>
|
||||
))}
|
||||
{missingCount > 3 && (
|
||||
<li className="text-xs text-amber-600 dark:text-amber-500">
|
||||
and {missingCount - 3} more.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to="/library?filter=missingFiles"
|
||||
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
||||
>
|
||||
|
||||
<span className="underline">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
|
||||
View Missing Files In Library
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import React, { useMemo, ReactElement, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
import T2Table from "../shared/T2Table";
|
||||
@@ -12,79 +11,130 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { format, fromUnixTime, parseISO } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useGetWantedComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type FilterOption = "all" | "missingFiles";
|
||||
|
||||
interface SearchQuery {
|
||||
query: Record<string, any>;
|
||||
pagination: { size: number; from: number };
|
||||
type: string;
|
||||
trigger: string;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: "all", label: "All Comics" },
|
||||
{ value: "missingFiles", label: "Missing Files" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* <Library />
|
||||
* Library page component. Displays a paginated, searchable table of all comics
|
||||
* in the collection, with an optional filter for comics with missing raw files.
|
||||
*/
|
||||
export const Library = (): ReactElement => {
|
||||
// Default page state
|
||||
// offset: 0
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState({
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: offset,
|
||||
},
|
||||
pagination: { size: 25, from: 0 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||
*/
|
||||
const fetchIssues = async (searchQuery) => {
|
||||
const { pagination, query, type } = searchQuery;
|
||||
/** Fetches a page of issues from the search API. */
|
||||
const fetchIssues = async (q: SearchQuery) => {
|
||||
const { pagination, query, type } = q;
|
||||
return await axios({
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/api/search/searchIssue",
|
||||
data: {
|
||||
query,
|
||||
pagination,
|
||||
type,
|
||||
},
|
||||
data: { query, pagination, type },
|
||||
});
|
||||
};
|
||||
|
||||
const searchIssues = (e) => {
|
||||
const { data, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: activeFilter === "all",
|
||||
});
|
||||
|
||||
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 25, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "missingFiles" },
|
||||
);
|
||||
|
||||
const { data: missingIdsData } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: { limit: 1000, page: 1 },
|
||||
predicate: { "importStatus.isRawFileMissing": true },
|
||||
},
|
||||
{ enabled: activeFilter === "all" },
|
||||
);
|
||||
|
||||
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
||||
const missingIdSet = useMemo(
|
||||
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
||||
[missingIdsData],
|
||||
);
|
||||
|
||||
const searchResults = data?.data;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
||||
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
||||
|
||||
/** Triggers a search by volume name and resets pagination. */
|
||||
const searchIssues = (e: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {
|
||||
volumeName: e.search,
|
||||
},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: 0,
|
||||
},
|
||||
query: { volumeName: e.search },
|
||||
pagination: { size: 15, from: 0 },
|
||||
type: "volumeName",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||
queryKey: ["comics", offset, searchQuery],
|
||||
queryFn: () => fetchIssues(searchQuery),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const searchResults = data?.data;
|
||||
// Programmatically navigate to comic detail
|
||||
const navigate = useNavigate();
|
||||
const navigateToComicDetail = (row) => {
|
||||
navigate(`/comic/details/${row.original._id}`);
|
||||
/** Advances to the next page of results. */
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
/** Goes back to the previous page of results. */
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: { size: 15, from },
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
};
|
||||
|
||||
const ComicInfoXML = (value: any) =>
|
||||
value.data ? (
|
||||
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
|
||||
{/* Series Name */}
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
@@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
||||
{/* Pages */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||
@@ -103,7 +152,6 @@ export const Library = (): ReactElement => {
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||
@@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
|
||||
</div>
|
||||
</dl>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const missingFilesColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Missing Files",
|
||||
columns: [
|
||||
{
|
||||
header: "Status",
|
||||
id: "missingStatus",
|
||||
cell: () => (
|
||||
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
|
||||
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
|
||||
MISSING
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Comic",
|
||||
id: "missingComic",
|
||||
minWidth: 250,
|
||||
accessorFn: (row: any) => row,
|
||||
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
footer: 1,
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 250,
|
||||
accessorKey: "_source",
|
||||
cell: (info) => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
cell: (info: any) => {
|
||||
const source = info.getValue();
|
||||
return (
|
||||
<MetadataPanel
|
||||
data={source}
|
||||
isMissing={missingIdSet.has(info.row.original._id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
cell: (info) =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : null,
|
||||
cell: (info: any) =>
|
||||
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -150,36 +230,30 @@ export const Library = (): ReactElement => {
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
cell: (info: any) =>
|
||||
!isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
cell: (info: any) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
|
||||
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
@@ -187,130 +261,100 @@ export const Library = (): ReactElement => {
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
[missingIdSet],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the next x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
*
|
||||
**/
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(pageSize * pageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the previous x (pageSize) items
|
||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
**/
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(from);
|
||||
};
|
||||
|
||||
// ImportStatus.propTypes = {
|
||||
// value: PropTypes.bool.isRequired,
|
||||
// };
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!isUndefined(searchResults?.hits) ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div>
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
paginationHandlers={{
|
||||
nextPage,
|
||||
previousPage,
|
||||
}}
|
||||
>
|
||||
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||
</T2Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no
|
||||
indices. Try importing a few comics into the library and come
|
||||
back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
|
||||
{!isUndefined(searchResults?.data?.meta?.body) ? (
|
||||
<p>
|
||||
{JSON.stringify(
|
||||
searchResults?.data.meta.body.error.root_cause,
|
||||
null,
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
const FilterDropdown = () => (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={activeFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
||||
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isMissingFilter = activeFilter === "missingFiles";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Library
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse your comic book collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isMissingFilter ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{isMissingLoading ? (
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
) : (
|
||||
<T2Table
|
||||
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
|
||||
columns={missingFilesColumns}
|
||||
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
|
||||
rowClickHandler={navigateToMissingComicDetail}
|
||||
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
|
||||
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
|
||||
>
|
||||
<FilterDropdown />
|
||||
</T2Table>
|
||||
)}
|
||||
</div>
|
||||
) : !isUndefined(searchResults?.hits) ? (
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
getRowClassName={(row) =>
|
||||
missingIdSet.has(row.original._id)
|
||||
? "bg-card-missing/40 hover:bg-card-missing/20"
|
||||
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
|
||||
}
|
||||
paginationHandlers={{ nextPage, previousPage }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterDropdown />
|
||||
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
|
||||
</div>
|
||||
</T2Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no indices. Try
|
||||
importing a few comics into the library and come back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<FilterDropdown />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Library;
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ICardProps {
|
||||
children?: PropTypes.ReactNodeLike;
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: React.CSSProperties;
|
||||
imageStyle?: React.CSSProperties;
|
||||
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
|
||||
return "bg-card-uncompressed";
|
||||
case "imported":
|
||||
return "bg-card-imported";
|
||||
case "missing":
|
||||
return "bg-card-missing";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -102,11 +104,22 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
case "vertical-2":
|
||||
return (
|
||||
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
/>
|
||||
<div className="relative">
|
||||
{props.imageUrl ? (
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
|
||||
)}
|
||||
{props.cardState === "missing" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-16 h-16 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.title ? (
|
||||
<div className="px-3 pt-3 mb-2">
|
||||
|
||||
@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { find, isUndefined } from "lodash";
|
||||
|
||||
interface IMetadatPanelProps {
|
||||
value: any;
|
||||
children: any;
|
||||
imageStyle: any;
|
||||
titleStyle: any;
|
||||
tagsStyle: any;
|
||||
containerStyle: any;
|
||||
data: any;
|
||||
value?: any;
|
||||
children?: any;
|
||||
imageStyle?: any;
|
||||
titleStyle?: any;
|
||||
tagsStyle?: any;
|
||||
containerStyle?: any;
|
||||
isMissing?: boolean;
|
||||
}
|
||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const { isMissing = false } = props;
|
||||
const {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
@@ -31,8 +34,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
{
|
||||
name: "rawFileDetails",
|
||||
content: () => (
|
||||
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
|
||||
<dt>
|
||||
<dl
|
||||
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
|
||||
>
|
||||
<dt className="flex items-center gap-2">
|
||||
<p className="text-sm sm:text-lg">{issueName}</p>
|
||||
</dt>
|
||||
<dd className="text-xs sm:text-sm">
|
||||
@@ -58,26 +63,35 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
)}
|
||||
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
{rawFileDetails.mimeType && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
{rawFileDetails.fileSize != null && (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
{/* Missing file Icon */}
|
||||
{isMissing && (
|
||||
<span className="pr-2 pt-1" title="File backing this comic is missing">
|
||||
<i className="icon-[solar--file-corrupted-outline] w-5 h-5 text-red-600 shrink-0"></i>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncompressed version available? */}
|
||||
{rawFileDetails.archive?.uncompressed && (
|
||||
@@ -177,7 +191,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
const metadataPanel = find(metadataContentPanel, {
|
||||
name: objectReference,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
||||
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
||||
@@ -188,7 +201,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">{metadataPanel.content()}</div>
|
||||
<div className="flex-1">{metadataPanel?.content()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,69 +1,89 @@
|
||||
import React, { ReactElement, useMemo, useState } from "react";
|
||||
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
Row,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
useReactTable,
|
||||
PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
interface T2TableProps {
|
||||
sourceData?: unknown[];
|
||||
/** Props for {@link T2Table}. */
|
||||
interface T2TableProps<TData> {
|
||||
/** Row data to render. */
|
||||
sourceData?: TData[];
|
||||
/** Total number of records across all pages, used for pagination display. */
|
||||
totalPages?: number;
|
||||
columns?: unknown[];
|
||||
/** Column definitions (TanStack Table {@link ColumnDef} array). */
|
||||
columns?: ColumnDef<TData>[];
|
||||
/** Callbacks for navigating between pages. */
|
||||
paginationHandlers?: {
|
||||
nextPage?(...args: unknown[]): unknown;
|
||||
previousPage?(...args: unknown[]): unknown;
|
||||
nextPage?(pageIndex: number, pageSize: number): void;
|
||||
previousPage?(pageIndex: number, pageSize: number): void;
|
||||
};
|
||||
rowClickHandler?(...args: unknown[]): unknown;
|
||||
children?: any;
|
||||
/** Called with the TanStack row object when a row is clicked. */
|
||||
rowClickHandler?(row: Row<TData>): void;
|
||||
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
|
||||
getRowClassName?(row: Row<TData>): string;
|
||||
/** Optional slot rendered in the toolbar area (e.g. a search input). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
const {
|
||||
sourceData,
|
||||
columns,
|
||||
paginationHandlers: { nextPage, previousPage },
|
||||
totalPages,
|
||||
rowClickHandler,
|
||||
} = tableOptions;
|
||||
/**
|
||||
* A paginated data table with a two-row sticky header.
|
||||
*
|
||||
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
|
||||
* element placed immediately before the table. The second header row's `top`
|
||||
* offset is measured at mount time so both rows stay flush regardless of font
|
||||
* size or padding changes.
|
||||
*/
|
||||
export const T2Table = <TData,>({
|
||||
sourceData = [],
|
||||
columns = [],
|
||||
paginationHandlers: { nextPage, previousPage } = {},
|
||||
totalPages = 0,
|
||||
rowClickHandler,
|
||||
getRowClassName,
|
||||
children,
|
||||
}: T2TableProps<TData>): ReactElement => {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [firstRowHeight, setFirstRowHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsSticky(!entry.isIntersecting),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (firstHeaderRowRef.current)
|
||||
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
|
||||
}, []);
|
||||
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 1,
|
||||
pageSize: 15,
|
||||
});
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||
|
||||
/**
|
||||
* Pagination control to move forward one page
|
||||
* @returns void
|
||||
*/
|
||||
/** Advances to the next page and notifies the parent. */
|
||||
const goToNextPage = () => {
|
||||
setPagination({
|
||||
pageIndex: pageIndex + 1,
|
||||
pageSize,
|
||||
});
|
||||
nextPage(pageIndex, pageSize);
|
||||
setPagination({ pageIndex: pageIndex + 1, pageSize });
|
||||
nextPage?.(pageIndex, pageSize);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control to move backward one page
|
||||
* @returns void
|
||||
**/
|
||||
/** Goes back one page and notifies the parent. */
|
||||
const goToPreviousPage = () => {
|
||||
setPagination({
|
||||
pageIndex: pageIndex - 1,
|
||||
pageSize,
|
||||
});
|
||||
previousPage(pageIndex, pageSize);
|
||||
setPagination({ pageIndex: pageIndex - 1, pageSize });
|
||||
previousPage?.(pageIndex, pageSize);
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -72,63 +92,62 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
pageCount: sourceData.length ?? -1,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container max-w-fit">
|
||||
<div>
|
||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||
{/* Search bar */}
|
||||
{tableOptions.children}
|
||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||
{children}
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||
<div className="mb-1">
|
||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||
{totalPages} comics in all
|
||||
</p>
|
||||
<div className="inline-flex flex-row mt-3">
|
||||
<button
|
||||
onClick={() => goToPreviousPage()}
|
||||
disabled={pageIndex === 1}
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
||||
>
|
||||
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
|
||||
</button>
|
||||
<button
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||
onClick={() => goToNextPage()}
|
||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
||||
>
|
||||
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||
<div className="mb-1">
|
||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||
{totalPages} comics in all
|
||||
</p>
|
||||
<div className="inline-flex flex-row mt-3">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={pageIndex === 1}
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
||||
>
|
||||
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||
>
|
||||
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400 border-b border-gray-300 dark:border-slate-700"
|
||||
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
|
||||
className={[
|
||||
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
|
||||
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
|
||||
groupIndex === 0
|
||||
? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
|
||||
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
|
||||
].join(' ')}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -136,11 +155,11 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, rowIndex) => (
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => rowClickHandler(row)}
|
||||
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer"
|
||||
onClick={() => rowClickHandler?.(row)}
|
||||
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2 align-top">
|
||||
|
||||
@@ -28,6 +28,23 @@ export type AcquisitionSourceInput = {
|
||||
wanted?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type AddTorrentInput = {
|
||||
comicObjectId: Scalars['ID']['input'];
|
||||
torrentToDownload: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AddTorrentResult = {
|
||||
__typename?: 'AddTorrentResult';
|
||||
result?: Maybe<Scalars['JSON']['output']>;
|
||||
};
|
||||
|
||||
export type AppSettings = {
|
||||
__typename?: 'AppSettings';
|
||||
bittorrent?: Maybe<BittorrentSettings>;
|
||||
directConnect?: Maybe<DirectConnectSettings>;
|
||||
prowlarr?: Maybe<ProwlarrSettings>;
|
||||
};
|
||||
|
||||
export type Archive = {
|
||||
__typename?: 'Archive';
|
||||
expandedPath?: Maybe<Scalars['String']['output']>;
|
||||
@@ -52,6 +69,26 @@ export type AutoMergeSettingsInput = {
|
||||
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type BittorrentClient = {
|
||||
__typename?: 'BittorrentClient';
|
||||
host?: Maybe<HostConfig>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type BittorrentSettings = {
|
||||
__typename?: 'BittorrentSettings';
|
||||
client?: Maybe<BittorrentClient>;
|
||||
};
|
||||
|
||||
export type Bundle = {
|
||||
__typename?: 'Bundle';
|
||||
id?: Maybe<Scalars['Int']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
size?: Maybe<Scalars['String']['output']>;
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
status?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type CanonicalMetadata = {
|
||||
__typename?: 'CanonicalMetadata';
|
||||
ageRating?: Maybe<MetadataField>;
|
||||
@@ -123,6 +160,11 @@ export type ComicConnection = {
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type ComicVineMatchInput = {
|
||||
volume: ComicVineVolumeRefInput;
|
||||
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
|
||||
};
|
||||
|
||||
export type ComicVineResourceResponse = {
|
||||
__typename?: 'ComicVineResourceResponse';
|
||||
error: Scalars['String']['output'];
|
||||
@@ -143,6 +185,24 @@ export type ComicVineSearchResult = {
|
||||
offset: Scalars['Int']['output'];
|
||||
results: Array<SearchResultItem>;
|
||||
status_code: Scalars['Int']['output'];
|
||||
total: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type ComicVineVolume = {
|
||||
__typename?: 'ComicVineVolume';
|
||||
api_detail_url?: Maybe<Scalars['String']['output']>;
|
||||
count_of_issues?: Maybe<Scalars['Int']['output']>;
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id?: Maybe<Scalars['Int']['output']>;
|
||||
image?: Maybe<VolumeImage>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
publisher?: Maybe<Publisher>;
|
||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
||||
start_year?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ComicVineVolumeRefInput = {
|
||||
api_detail_url: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export enum ConflictResolutionStrategy {
|
||||
@@ -177,10 +237,22 @@ export type DirectConnectBundleInput = {
|
||||
size?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type DirectConnectClient = {
|
||||
__typename?: 'DirectConnectClient';
|
||||
airDCPPUserSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
host?: Maybe<HostConfig>;
|
||||
hubs?: Maybe<Array<Maybe<Scalars['JSON']['output']>>>;
|
||||
};
|
||||
|
||||
export type DirectConnectInput = {
|
||||
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
|
||||
};
|
||||
|
||||
export type DirectConnectSettings = {
|
||||
__typename?: 'DirectConnectSettings';
|
||||
client?: Maybe<DirectConnectClient>;
|
||||
};
|
||||
|
||||
export type DirectorySize = {
|
||||
__typename?: 'DirectorySize';
|
||||
fileCount: Scalars['Int']['output'];
|
||||
@@ -234,6 +306,37 @@ export type GetVolumesInput = {
|
||||
volumeURI: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type HostConfig = {
|
||||
__typename?: 'HostConfig';
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
password?: Maybe<Scalars['String']['output']>;
|
||||
port?: Maybe<Scalars['String']['output']>;
|
||||
protocol?: Maybe<Scalars['String']['output']>;
|
||||
username?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type HostInput = {
|
||||
hostname: Scalars['String']['input'];
|
||||
password: Scalars['String']['input'];
|
||||
port: Scalars['String']['input'];
|
||||
protocol: Scalars['String']['input'];
|
||||
username: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Hub = {
|
||||
__typename?: 'Hub';
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id?: Maybe<Scalars['Int']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
userCount?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type ImageAnalysisResult = {
|
||||
__typename?: 'ImageAnalysisResult';
|
||||
analyzedData?: Maybe<Scalars['JSON']['output']>;
|
||||
colorHistogramData?: Maybe<Scalars['JSON']['output']>;
|
||||
};
|
||||
|
||||
export type ImageUrls = {
|
||||
__typename?: 'ImageUrls';
|
||||
icon_url?: Maybe<Scalars['String']['output']>;
|
||||
@@ -303,6 +406,7 @@ export type ImportStatistics = {
|
||||
export type ImportStats = {
|
||||
__typename?: 'ImportStats';
|
||||
alreadyImported: Scalars['Int']['output'];
|
||||
missingFiles: Scalars['Int']['output'];
|
||||
newFiles: Scalars['Int']['output'];
|
||||
percentageImported: Scalars['String']['output'];
|
||||
totalLocalFiles: Scalars['Int']['output'];
|
||||
@@ -311,6 +415,7 @@ export type ImportStats = {
|
||||
export type ImportStatus = {
|
||||
__typename?: 'ImportStatus';
|
||||
isImported?: Maybe<Scalars['Boolean']['output']>;
|
||||
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
|
||||
matchedResult?: Maybe<MatchedResult>;
|
||||
tagged?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
@@ -421,6 +526,7 @@ export type LocgMetadataInput = {
|
||||
export type LibraryStatistics = {
|
||||
__typename?: 'LibraryStatistics';
|
||||
comicDirectorySize: DirectorySize;
|
||||
comicsMissingFiles: Scalars['Int']['output'];
|
||||
statistics: Array<StatisticsFacet>;
|
||||
totalDocuments: Scalars['Int']['output'];
|
||||
};
|
||||
@@ -515,6 +621,10 @@ export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
/** Placeholder for future mutations */
|
||||
_empty?: Maybe<Scalars['String']['output']>;
|
||||
/** Add a torrent to qBittorrent */
|
||||
addTorrent?: Maybe<AddTorrentResult>;
|
||||
analyzeImage: ImageAnalysisResult;
|
||||
applyComicVineMatch: Comic;
|
||||
bulkResolveMetadata: Array<Comic>;
|
||||
forceCompleteSession: ForceCompleteResult;
|
||||
importComic: ImportComicResult;
|
||||
@@ -524,11 +634,28 @@ export type Mutation = {
|
||||
setMetadataField: Comic;
|
||||
startIncrementalImport: IncrementalImportResult;
|
||||
startNewImport: ImportJobResult;
|
||||
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
|
||||
updateSourcedMetadata: Comic;
|
||||
updateUserPreferences: UserPreferences;
|
||||
};
|
||||
|
||||
|
||||
export type MutationAddTorrentArgs = {
|
||||
input: AddTorrentInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationAnalyzeImageArgs = {
|
||||
imageFilePath: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationApplyComicVineMatchArgs = {
|
||||
comicObjectId: Scalars['ID']['input'];
|
||||
match: ComicVineMatchInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationBulkResolveMetadataArgs = {
|
||||
comicIds: Array<Scalars['ID']['input']>;
|
||||
};
|
||||
@@ -579,6 +706,13 @@ export type MutationStartNewImportArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUncompressArchiveArgs = {
|
||||
comicObjectId: Scalars['ID']['input'];
|
||||
filePath: Scalars['String']['input'];
|
||||
options?: InputMaybe<Scalars['JSON']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateSourcedMetadataArgs = {
|
||||
comicId: Scalars['ID']['input'];
|
||||
metadata: Scalars['String']['input'];
|
||||
@@ -627,6 +761,17 @@ export type Provenance = {
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ProwlarrClient = {
|
||||
__typename?: 'ProwlarrClient';
|
||||
apiKey?: Maybe<Scalars['String']['output']>;
|
||||
host?: Maybe<HostConfig>;
|
||||
};
|
||||
|
||||
export type ProwlarrSettings = {
|
||||
__typename?: 'ProwlarrSettings';
|
||||
client?: Maybe<ProwlarrClient>;
|
||||
};
|
||||
|
||||
export type Publisher = {
|
||||
__typename?: 'Publisher';
|
||||
api_detail_url?: Maybe<Scalars['String']['output']>;
|
||||
@@ -642,7 +787,9 @@ export type PublisherStats = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
_empty?: Maybe<Scalars['String']['output']>;
|
||||
analyzeMetadataConflicts: Array<MetadataConflict>;
|
||||
bundles: Array<Bundle>;
|
||||
comic?: Maybe<Comic>;
|
||||
comics: ComicConnection;
|
||||
/** Fetch resource from Metron API */
|
||||
@@ -663,13 +810,18 @@ export type Query = {
|
||||
getVolume: VolumeDetailResponse;
|
||||
/** Get weekly pull list from League of Comic Geeks */
|
||||
getWeeklyPullList: MetadataPullListResponse;
|
||||
hubs: Array<Hub>;
|
||||
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
|
||||
/** Search ComicVine for volumes, issues, characters, etc. */
|
||||
searchComicVine: ComicVineSearchResult;
|
||||
searchIssue: SearchIssueResult;
|
||||
searchTorrents: Array<TorrentSearchResult>;
|
||||
settings?: Maybe<AppSettings>;
|
||||
torrentJobs?: Maybe<TorrentJob>;
|
||||
userPreferences?: Maybe<UserPreferences>;
|
||||
/** Advanced volume-based search with scoring and filtering */
|
||||
volumeBasedSearch: VolumeBasedSearchResponse;
|
||||
walkFolders: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -678,6 +830,12 @@ export type QueryAnalyzeMetadataConflictsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryBundlesArgs = {
|
||||
comicObjectId: Scalars['ID']['input'];
|
||||
config?: InputMaybe<Scalars['JSON']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryComicArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -733,6 +891,11 @@ export type QueryGetWeeklyPullListArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryHubsArgs = {
|
||||
host: HostInput;
|
||||
};
|
||||
|
||||
|
||||
export type QueryPreviewCanonicalMetadataArgs = {
|
||||
comicId: Scalars['ID']['input'];
|
||||
preferences?: InputMaybe<UserPreferencesInput>;
|
||||
@@ -751,6 +914,21 @@ export type QuerySearchIssueArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QuerySearchTorrentsArgs = {
|
||||
query: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QuerySettingsArgs = {
|
||||
settingsKey?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryTorrentJobsArgs = {
|
||||
trigger: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryUserPreferencesArgs = {
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -760,6 +938,12 @@ export type QueryVolumeBasedSearchArgs = {
|
||||
input: VolumeSearchInput;
|
||||
};
|
||||
|
||||
|
||||
export type QueryWalkFoldersArgs = {
|
||||
basePathToWalk: Scalars['String']['input'];
|
||||
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
};
|
||||
|
||||
export type RawFileDetails = {
|
||||
__typename?: 'RawFileDetails';
|
||||
archive?: Maybe<Archive>;
|
||||
@@ -938,6 +1122,24 @@ export type TeamCredit = {
|
||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type TorrentJob = {
|
||||
__typename?: 'TorrentJob';
|
||||
id?: Maybe<Scalars['String']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type TorrentSearchResult = {
|
||||
__typename?: 'TorrentSearchResult';
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
guid?: Maybe<Scalars['String']['output']>;
|
||||
indexer?: Maybe<Scalars['String']['output']>;
|
||||
leechers?: Maybe<Scalars['Int']['output']>;
|
||||
publishDate?: Maybe<Scalars['String']['output']>;
|
||||
seeders?: Maybe<Scalars['Int']['output']>;
|
||||
size?: Maybe<Scalars['Float']['output']>;
|
||||
title?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
__typename?: 'UserPreferences';
|
||||
autoMerge: AutoMergeSettings;
|
||||
@@ -1083,7 +1285,7 @@ export type GetRecentComicsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null }> } };
|
||||
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null, importStatus?: { __typename?: 'ImportStatus', isRawFileMissing?: boolean | null } | null }> } };
|
||||
|
||||
export type GetWantedComicsQueryVariables = Exact<{
|
||||
paginationOptions: PaginationOptionsInput;
|
||||
@@ -1101,7 +1303,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
|
||||
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
|
||||
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicsMissingFiles: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number, totalSizeInGB: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
|
||||
|
||||
export type GetWeeklyPullListQueryVariables = Exact<{
|
||||
input: WeeklyPullListInput;
|
||||
@@ -1115,7 +1317,7 @@ export type GetImportStatisticsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } };
|
||||
export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, missingFiles: number, percentageImported: string } } };
|
||||
|
||||
export type StartNewImportMutationVariables = Exact<{
|
||||
sessionId: Scalars['String']['input'];
|
||||
@@ -1524,6 +1726,9 @@ export const GetRecentComicsDocument = `
|
||||
value
|
||||
}
|
||||
}
|
||||
importStatus {
|
||||
isRawFileMissing
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -1739,8 +1944,10 @@ export const GetLibraryStatisticsDocument = `
|
||||
query GetLibraryStatistics {
|
||||
getLibraryStatistics {
|
||||
totalDocuments
|
||||
comicsMissingFiles
|
||||
comicDirectorySize {
|
||||
fileCount
|
||||
totalSizeInGB
|
||||
}
|
||||
statistics {
|
||||
fileTypes {
|
||||
@@ -1874,6 +2081,7 @@ export const GetImportStatisticsDocument = `
|
||||
totalLocalFiles
|
||||
alreadyImported
|
||||
newFiles
|
||||
missingFiles
|
||||
percentageImported
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ query GetRecentComics($limit: Int) {
|
||||
value
|
||||
}
|
||||
}
|
||||
importStatus {
|
||||
isRawFileMissing
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -176,8 +179,10 @@ query GetVolumeGroups {
|
||||
query GetLibraryStatistics {
|
||||
getLibraryStatistics {
|
||||
totalDocuments
|
||||
comicsMissingFiles
|
||||
comicDirectorySize {
|
||||
fileCount
|
||||
totalSizeInGB
|
||||
}
|
||||
statistics {
|
||||
fileTypes {
|
||||
|
||||
@@ -6,6 +6,7 @@ query GetImportStatistics($directoryPath: String) {
|
||||
totalLocalFiles
|
||||
alreadyImported
|
||||
newFiles
|
||||
missingFiles
|
||||
percentageImported
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
// Track if we've received completion events
|
||||
const completionEventReceived = useRef(false);
|
||||
const queueDrainedEventReceived = useRef(false);
|
||||
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
|
||||
// Prevents a stale "running" DB session from showing as active on hard refresh.
|
||||
const sessionStartedEventReceived = useRef(false);
|
||||
|
||||
// Query active import session - NO POLLING, only refetch on Socket.IO events
|
||||
// Query active import session - polls every 3s as a fallback when a session is
|
||||
// active (e.g. tab re-opened mid-import and socket events were missed)
|
||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false, // NO POLLING
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (query) => {
|
||||
const s = (query.state.data as any)?.getActiveImportSession;
|
||||
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
|
||||
// Case 3: Check if session is actually running/active
|
||||
if (status === "running" || status === "active" || status === "processing") {
|
||||
// Check if there's actual progress happening
|
||||
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
|
||||
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
||||
|
||||
// Only treat as active if there's progress OR it just started
|
||||
if (hasProgress && hasQueuedWork) {
|
||||
// Only treat as "just started" if the started event fired in this browser session.
|
||||
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
|
||||
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
||||
|
||||
// No in-session event AND no actual progress → stale unclosed session from a previous run.
|
||||
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
|
||||
const likelyStale = !sessionStartedEventReceived.current
|
||||
&& stats.filesProcessed === 0
|
||||
&& stats.filesSucceeded === 0;
|
||||
|
||||
if ((hasQueuedWork || justStarted) && !likelyStale) {
|
||||
return {
|
||||
status: "running",
|
||||
sessionId,
|
||||
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
isActive: true,
|
||||
};
|
||||
} else {
|
||||
// Session says "running" but no progress - likely stuck/stale
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
// Session says "running" but all files processed — likely a stale session
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
@@ -243,10 +258,11 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
};
|
||||
|
||||
const handleSessionStarted = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED event received");
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED / LS_INCREMENTAL_IMPORT_STARTED event received");
|
||||
// Reset completion flags when new session starts
|
||||
completionEventReceived.current = false;
|
||||
queueDrainedEventReceived.current = false;
|
||||
sessionStartedEventReceived.current = true;
|
||||
refetch();
|
||||
};
|
||||
|
||||
@@ -259,12 +275,14 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
};
|
||||
}, [getSocket, refetch]);
|
||||
|
||||
@@ -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,8 @@ module.exports = {
|
||||
scraped: "#b8edbc",
|
||||
uncompressed: "#FFF3E0",
|
||||
imported: "#d8dab0",
|
||||
missing: "#fee2e2",
|
||||
info: "#cdd9eb",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export function iconifyPlugin() {
|
||||
const iconCache = new Map();
|
||||
const collections = new Map();
|
||||
|
||||
function loadCollection(prefix) {
|
||||
if (collections.has(prefix)) {
|
||||
return collections.get(prefix);
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionPath = join(__dirname, 'node_modules', '@iconify-json', prefix, 'icons.json');
|
||||
const data = JSON.parse(readFileSync(collectionPath, 'utf8'));
|
||||
collections.set(prefix, data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getIconCSS(iconData, selector) {
|
||||
const { body, width, height } = iconData;
|
||||
const viewBox = `0 0 ${width || 24} ${height || 24}`;
|
||||
|
||||
// Create SVG data URI
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">${body}</svg>`;
|
||||
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
|
||||
return `${selector} {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: url("${dataUri}");
|
||||
mask-image: url("${dataUri}");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}`;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-iconify',
|
||||
|
||||
transform(code, id) {
|
||||
// Only process files that might contain icon classes
|
||||
if (!id.endsWith('.tsx') && !id.endsWith('.jsx') && !id.endsWith('.ts') && !id.endsWith('.js')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find all icon-[...] patterns
|
||||
const iconPattern = /icon-\[([^\]]+)\]/g;
|
||||
const matches = [...code.matchAll(iconPattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract unique icons
|
||||
const icons = new Set(matches.map(m => m[1]));
|
||||
|
||||
// Generate CSS for each icon
|
||||
for (const iconName of icons) {
|
||||
if (iconCache.has(iconName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse icon name (e.g., "solar--add-square-bold-duotone")
|
||||
const parts = iconName.split('--');
|
||||
if (parts.length !== 2) continue;
|
||||
|
||||
const [prefix, name] = parts;
|
||||
|
||||
// Load collection
|
||||
const collection = loadCollection(prefix);
|
||||
if (!collection || !collection.icons || !collection.icons[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get icon data
|
||||
const iconData = collection.icons[name];
|
||||
|
||||
// Generate CSS
|
||||
const selector = `.icon-\\[${iconName}\\]`;
|
||||
const iconCSS = getIconCSS(iconData, selector);
|
||||
|
||||
iconCache.set(iconName, iconCSS);
|
||||
} catch (e) {
|
||||
// Silently skip failed icons
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id === '/@iconify-css') {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === '/@iconify-css') {
|
||||
const allCSS = Array.from(iconCache.values()).join('\n');
|
||||
return allCSS;
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml() {
|
||||
// Inject icon CSS into HTML
|
||||
const allCSS = Array.from(iconCache.values()).join('\n');
|
||||
if (allCSS) {
|
||||
return [
|
||||
{
|
||||
tag: 'style',
|
||||
attrs: { type: 'text/css' },
|
||||
children: allCSS,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user