Compare commits
9 Commits
dependabot
...
ba1b5bb965
| Author | SHA1 | Date | |
|---|---|---|---|
| ba1b5bb965 | |||
| 8546641152 | |||
| 867935be39 | |||
| d506cf8ba8 | |||
| 71d7034d01 | |||
| a217d447fa | |||
| 20336e5569 | |||
| 8913e9cd99 | |||
| c392333170 |
2166
package-lock.json
generated
2166
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
|||||||
"@floating-ui/react-dom": "^2.1.7",
|
"@floating-ui/react-dom": "^2.1.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
|||||||
@@ -67,13 +67,8 @@ type ComicDetailProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for displaying the metadata for a comic in greater detail.
|
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
||||||
*
|
* for metadata, archive operations, and acquisition.
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* return (
|
|
||||||
* <ComicDetail/>
|
|
||||||
* )
|
|
||||||
*/
|
*/
|
||||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
@@ -84,7 +79,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||||
acquisition,
|
acquisition,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
|
||||||
},
|
},
|
||||||
userSettings,
|
userSettings,
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -94,24 +88,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
const [modalIsOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
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
|
// Action event handlers
|
||||||
const openDrawerWithCVMatches = () => {
|
const openDrawerWithCVMatches = () => {
|
||||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||||
@@ -224,10 +204,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
<div className="grid">
|
<div className="grid">
|
||||||
<RawFileDetails
|
<RawFileDetails
|
||||||
data={{
|
data={{
|
||||||
rawFileDetails: rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata: inferredMetadata,
|
inferredMetadata,
|
||||||
created_at: createdAt,
|
createdAt,
|
||||||
updated_at: updatedAt,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* action dropdown */}
|
{/* action dropdown */}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
interface ComicVineMatchPanelProps {
|
||||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props;
|
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(
|
const { comicvine } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
comicvine: state.comicvine,
|
comicvine: state.comicvine,
|
||||||
|
|||||||
@@ -1,55 +1,41 @@
|
|||||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field, FieldRenderProps } from "react-final-form";
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
|
||||||
export const EditMetadataPanel = (props): ReactElement => {
|
interface EditMetadataPanelProps {
|
||||||
const validate = async () => {};
|
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
validate={validate}
|
mutators={{ ...arrayMutators }}
|
||||||
mutators={{
|
|
||||||
...arrayMutators,
|
|
||||||
}}
|
|
||||||
render={({
|
render={({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
form: {
|
form: {
|
||||||
mutators: { push, pop },
|
mutators: { push, pop },
|
||||||
}, // injected from final-form-arrays above
|
},
|
||||||
pristine,
|
|
||||||
form,
|
|
||||||
submitting,
|
|
||||||
values,
|
|
||||||
}) => (
|
}) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Issue Name */}
|
{/* Issue Name */}
|
||||||
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
<p className="text-xs">Do not enter the first zero</p>
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{/* year */}
|
|
||||||
<div className="text-sm">Issue Year</div>
|
<div className="text-sm">Issue Year</div>
|
||||||
<Field
|
<Field
|
||||||
name="issue_year"
|
name="issue_year"
|
||||||
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* page count */}
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<label className="text-sm">Description</label>
|
<label className="text-sm">Description</label>
|
||||||
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-label">
|
<div className="field-label">
|
||||||
@@ -153,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
{/* Publisher */}
|
{/* Publisher */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -224,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr />
|
||||||
|
|
||||||
{/* team credits */}
|
{/* team credits */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -302,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
import React, { ReactElement, ReactNode } from "react";
|
import React, { ReactElement, ReactNode } from "react";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
import {
|
||||||
|
RawFileDetails as RawFileDetailsType,
|
||||||
|
InferredMetadata,
|
||||||
|
} from "../../graphql/generated";
|
||||||
|
|
||||||
type RawFileDetailsProps = {
|
type RawFileDetailsProps = {
|
||||||
data?: {
|
data?: {
|
||||||
rawFileDetails?: RawFileDetailsType;
|
rawFileDetails?: RawFileDetailsType;
|
||||||
inferredMetadata?: {
|
inferredMetadata?: InferredMetadata;
|
||||||
issue?: {
|
createdAt?: string;
|
||||||
year?: string;
|
|
||||||
name?: string;
|
|
||||||
number?: number;
|
|
||||||
subtitle?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
};
|
};
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
||||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
||||||
props.data || {};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-2xl ml-5">
|
<div className="max-w-2xl ml-5">
|
||||||
@@ -97,10 +92,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
|||||||
Import Details
|
Import Details
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
<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(createdAt), "dd MMMM, yyyy")},{" "}
|
||||||
{format(parseISO(created_at), "h aaaa")}
|
{format(parseISO(createdAt), "h aaaa")}
|
||||||
</>
|
</>
|
||||||
) : "N/A"}
|
) : "N/A"}
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { isEmpty, isUndefined, map } from "lodash";
|
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
|
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
||||||
|
|
||||||
type LibraryStatisticsProps = {
|
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
||||||
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
|
comicDirectorySize: DirectorySize;
|
||||||
|
comicsMissingFiles: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LibraryStatistics = (
|
/** Props for {@link LibraryStatistics}. */
|
||||||
props: LibraryStatisticsProps,
|
interface LibraryStatisticsProps {
|
||||||
): ReactElement => {
|
stats: Stats | null | undefined;
|
||||||
const { stats } = props;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) return null;
|
||||||
|
|
||||||
|
const facet = stats.statistics?.[0];
|
||||||
|
if (!facet) return null;
|
||||||
|
|
||||||
|
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
|
||||||
|
const topPublisher = publisherWithMostComicsInLibrary?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Header
|
<Header
|
||||||
headerContent="Your Library In Numbers"
|
headerContent="Your Library In Numbers"
|
||||||
subHeaderContent={
|
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
|
||||||
<span className="text-md">A brief snapshot of your library.</span>
|
|
||||||
}
|
|
||||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3 flex flex-row gap-5">
|
||||||
<div className="flex flex-row gap-5">
|
{/* Total records in database */}
|
||||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
<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">Library size</dt>
|
<dt className="text-lg font-medium text-gray-500">In database</dt>
|
||||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
<dd className="text-3xl text-gray-700 md:text-5xl">
|
||||||
{props.stats.totalDocuments} files
|
{stats.totalDocuments} comics
|
||||||
</dd>
|
</dd>
|
||||||
{props.stats.comicDirectorySize?.fileCount && (
|
|
||||||
<dd>
|
|
||||||
<span className="text-2xl text-green-600">
|
|
||||||
{props.stats.comicDirectorySize.fileCount} comic files
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* comicinfo and comicvine tagged issues */}
|
|
||||||
|
{/* 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">
|
<div className="flex flex-col gap-4">
|
||||||
{!isUndefined(props.stats.statistics) &&
|
{issues && issues.length > 0 && (
|
||||||
!isEmpty(props.stats.statistics?.[0]?.issues) && (
|
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||||
<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 text-gray-700">{issues.length}</span>
|
||||||
<span className="text-xl">
|
|
||||||
{props.stats.statistics?.[0]?.issues?.length || 0}
|
|
||||||
</span>{" "}
|
|
||||||
tagged with ComicVine
|
tagged with ComicVine
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isUndefined(props.stats.statistics) &&
|
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
||||||
!isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && (
|
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
||||||
<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 text-gray-700">{issuesWithComicInfoXML.length}</span>
|
||||||
<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
|
with ComicInfo.xml
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="">
|
{/* File-type breakdown */}
|
||||||
{!isUndefined(props.stats.statistics) &&
|
{fileTypes && fileTypes.length > 0 && (
|
||||||
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
|
<div>
|
||||||
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
|
{fileTypes.map((ft) => (
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={ft.id}
|
||||||
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"
|
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
|
||||||
>
|
>
|
||||||
{fileType.data.length} {fileType.id}
|
{ft.data.length} {ft.id}
|
||||||
</span>
|
</span>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const RecentlyImported = (
|
|||||||
sourcedMetadata,
|
sourcedMetadata,
|
||||||
canonicalMetadata,
|
canonicalMetadata,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
|
importStatus,
|
||||||
} = comic;
|
} = comic;
|
||||||
|
|
||||||
// Parse sourced metadata (GraphQL returns as strings)
|
// Parse sourced metadata (GraphQL returns as strings)
|
||||||
@@ -63,7 +64,10 @@ export const RecentlyImported = (
|
|||||||
!isUndefined(comicvine) &&
|
!isUndefined(comicvine) &&
|
||||||
!isUndefined(comicvine.volumeInformation);
|
!isUndefined(comicvine.volumeInformation);
|
||||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||||
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
const isMissingFile = importStatus?.isRawFileMissing === true;
|
||||||
|
const cardState = isMissingFile
|
||||||
|
? "missing"
|
||||||
|
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -127,12 +131,6 @@ export const RecentlyImported = (
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { format } from "date-fns";
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
||||||
useGetJobResultStatisticsQuery,
|
|
||||||
useGetImportStatisticsQuery,
|
|
||||||
useStartIncrementalImportMutation
|
|
||||||
} from "../../graphql/generated";
|
|
||||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
interface ImportProps {
|
export const Import = (): ReactElement => {
|
||||||
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);
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
@@ -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
|
// Force re-import mutation - re-imports all files regardless of import status
|
||||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -78,101 +41,57 @@ export const Import = (props: ImportProps): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||||
|
|
||||||
// Get import statistics to determine if Start Import button should be shown
|
|
||||||
const { data: importStats } = useGetImportStatisticsQuery(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hook for definitive import session status tracking
|
|
||||||
// NO POLLING - relies on Socket.IO events only
|
|
||||||
const importSession = useImportSessionStatus();
|
const importSession = useImportSessionStatus();
|
||||||
|
|
||||||
const hasActiveSession = importSession.isActive;
|
const hasActiveSession = importSession.isActive;
|
||||||
|
const wasComplete = useRef(false);
|
||||||
|
|
||||||
// Determine if we should show the Start Import button
|
// React to importSession.isComplete rather than socket events — more reliable
|
||||||
const hasNewFiles = importStats?.getImportStatistics?.success &&
|
// since it's derived from the actual GraphQL state, not a raw socket event.
|
||||||
importStats.getImportStatistics.stats &&
|
useEffect(() => {
|
||||||
importStats.getImportStatistics.stats.newFiles > 0;
|
if (importSession.isComplete && !wasComplete.current) {
|
||||||
|
wasComplete.current = true;
|
||||||
|
// Small delay so the backend has time to commit job result stats
|
||||||
|
setTimeout(() => {
|
||||||
|
// Invalidate the cache to force a fresh fetch of job result statistics
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||||
|
refetch();
|
||||||
|
}, 1500);
|
||||||
|
importJobQueue.setStatus("drained");
|
||||||
|
} else if (!importSession.isComplete) {
|
||||||
|
wasComplete.current = false;
|
||||||
|
}
|
||||||
|
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
||||||
|
|
||||||
|
// Listen to socket events to update Past Imports table in real-time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
const handleQueueDrained = () => refetch();
|
|
||||||
const handleCoverExtracted = () => refetch();
|
|
||||||
|
|
||||||
const handleSessionStarted = () => {
|
const handleImportCompleted = () => {
|
||||||
importJobQueue.setStatus("running");
|
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 = () => {
|
const handleQueueDrained = () => {
|
||||||
refetch();
|
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
||||||
importJobQueue.setStatus("drained");
|
// Small delay to ensure backend has committed the job results
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
||||||
|
}, 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
|
||||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.off("IMPORT_SESSION_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
|
* Handles force re-import - re-imports all files to fix indexing issues
|
||||||
@@ -197,7 +116,6 @@ export const Import = (props: ImportProps): ReactElement => {
|
|||||||
disconnectSocket("/");
|
disconnectSocket("/");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
getSocket("/");
|
getSocket("/");
|
||||||
setSocketReconnectTrigger(prev => prev + 1);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
forceReImport();
|
forceReImport();
|
||||||
}, 500);
|
}, 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
@@ -327,53 +197,10 @@ export const Import = (props: ImportProps): ReactElement => {
|
|||||||
</div>
|
</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 */}
|
{/* Force Re-Import Button - always shown when no import is running */}
|
||||||
{!hasActiveSession &&
|
{!hasActiveSession &&
|
||||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
||||||
|
<div className="my-6 max-w-screen-lg">
|
||||||
<button
|
<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"
|
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}
|
onClick={handleForceReImport}
|
||||||
@@ -387,8 +214,8 @@ export const Import = (props: ImportProps): ReactElement => {
|
|||||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,77 @@
|
|||||||
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 {
|
import {
|
||||||
useGetImportStatisticsQuery,
|
useGetImportStatisticsQuery,
|
||||||
useStartIncrementalImportMutation
|
useGetWantedComicsQuery,
|
||||||
|
useStartIncrementalImportMutation,
|
||||||
} from "../../graphql/generated";
|
} from "../../graphql/generated";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import statistics with card-based layout and progress bar
|
* Import statistics with card-based layout and progress bar.
|
||||||
* Updates in real-time via the useImportSessionStatus hook
|
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
||||||
|
* Also surfaces missing files detected by the file watcher.
|
||||||
*/
|
*/
|
||||||
export const RealTimeImportStats = (): ReactElement => {
|
export const RealTimeImportStats = (): ReactElement => {
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
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(
|
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
getSocket: state.getSocket,
|
getSocket: state.getSocket,
|
||||||
disconnectSocket: state.disconnectSocket,
|
disconnectSocket: state.disconnectSocket,
|
||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get filesystem statistics (new files vs already imported)
|
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
||||||
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
|
|
||||||
{},
|
{},
|
||||||
{ refetchOnWindowFocus: false, refetchInterval: false }
|
{ refetchOnWindowFocus: false, refetchInterval: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get definitive import session status (handles Socket.IO events internally)
|
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 importSession = useImportSessionStatus();
|
||||||
|
|
||||||
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
|
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
||||||
|
useStartIncrementalImportMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.startIncrementalImport.success) {
|
if (data.startIncrementalImport.success) {
|
||||||
importJobQueue.setStatus("running");
|
importJobQueue.setStatus("running");
|
||||||
@@ -38,56 +79,89 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Failed to start import:", error);
|
setImportError(
|
||||||
setImportError(error?.message || "Failed to start import. Please try again.");
|
error?.message || "Failed to start import. Please try again.",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats = importStats?.getImportStatistics?.stats;
|
|
||||||
const hasNewFiles = stats && stats.newFiles > 0;
|
const hasNewFiles = stats && stats.newFiles > 0;
|
||||||
|
const missingCount = stats?.missingFiles ?? 0;
|
||||||
|
|
||||||
// Refetch filesystem stats when import completes
|
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
||||||
useEffect(() => {
|
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
||||||
if (importSession.isComplete && importSession.status === "completed") {
|
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
|
|
||||||
const handleFilesystemChange = () => {
|
const handleStatsChange = () => {
|
||||||
refetchStats();
|
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// File system changes that affect import statistics
|
const handleFileDetected = (payload: { filePath: string }) => {
|
||||||
socket.on("LS_FILE_ADDED", handleFilesystemChange);
|
handleStatsChange();
|
||||||
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
|
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
||||||
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
|
setDetectedFile(name);
|
||||||
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
setTimeout(() => setDetectedFile(null), 5000);
|
||||||
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
};
|
||||||
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
|
||||||
|
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 () => {
|
return () => {
|
||||||
socket.off("LS_FILE_ADDED", handleFilesystemChange);
|
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
||||||
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
|
socket.off("LS_FILES_MISSING", handleStatsChange);
|
||||||
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
|
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
||||||
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
|
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
||||||
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
|
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
||||||
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
|
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
||||||
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
};
|
};
|
||||||
}, [getSocket, refetchStats]);
|
}, [getSocket, queryClient]);
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
const handleStartImport = async () => {
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|
||||||
// Check if import is already active using definitive status
|
|
||||||
if (importSession.isActive) {
|
if (importSession.isActive) {
|
||||||
setImportError(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,24 +186,26 @@ export const RealTimeImportStats = (): ReactElement => {
|
|||||||
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
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 isFirstImport = stats.alreadyImported === 0;
|
||||||
const buttonText = isFirstImport
|
const buttonText = isFirstImport
|
||||||
? `Start Import (${stats.newFiles} files)`
|
? `Start Import (${stats.newFiles} files)`
|
||||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
: `Start Incremental Import (${stats.newFiles} new files)`;
|
||||||
|
|
||||||
// Calculate display statistics
|
// Determine what to show in each card based on current phase
|
||||||
const displayStats = importSession.isActive && importSession.stats
|
const sessionStats = importSession.stats;
|
||||||
? {
|
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
||||||
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
|
|
||||||
filesQueued: importSession.stats.filesQueued,
|
const totalFiles = stats.totalLocalFiles;
|
||||||
filesSucceeded: importSession.stats.filesSucceeded,
|
const importedCount = stats.alreadyImported;
|
||||||
}
|
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
||||||
: {
|
|
||||||
totalFiles: stats.totalLocalFiles,
|
const showProgressBar = socketImport !== null;
|
||||||
filesQueued: stats.newFiles,
|
const socketProgressPct =
|
||||||
filesSucceeded: stats.alreadyImported,
|
socketImport && socketImport.total > 0
|
||||||
};
|
? Math.round((socketImport.completed / socketImport.total) * 100)
|
||||||
|
: 0;
|
||||||
|
const showFailedCard = hasSessionStats && failedCount > 0;
|
||||||
|
const showMissingCard = missingCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">Import Error</p>
|
<p className="font-semibold text-red-800 dark:text-red-300">
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{importError}</p>
|
Import Error
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||||
|
{importError}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setImportError(null)}
|
onClick={() => setImportError(null)}
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
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>
|
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{hasNewFiles && !importSession.isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={handleStartImport}
|
onClick={handleStartImport}
|
||||||
disabled={isStartingImport}
|
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>
|
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||||
</span>
|
|
||||||
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Import Progress Bar */}
|
{/* Progress bar — shown while importing and once complete */}
|
||||||
{importSession.isActive && (
|
{showProgressBar && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
|
{socketImport!.active
|
||||||
|
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
||||||
|
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-white">
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
{Math.round(importSession.progress)}%
|
{socketProgressPct}% complete
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{/* Files Detected Card */}
|
{/* Total files */}
|
||||||
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
|
<div
|
||||||
<div className="text-4xl font-bold text-white mb-2">
|
className="rounded-lg p-6 text-center"
|
||||||
{displayStats.totalFiles}
|
style={{ backgroundColor: "#6b7280" }}
|
||||||
</div>
|
>
|
||||||
<div className="text-sm text-gray-200 font-medium">
|
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
||||||
files detected
|
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* To Import Card */}
|
{/* Imported */}
|
||||||
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
|
<div
|
||||||
<div className="text-4xl font-bold text-white mb-2">
|
className="rounded-lg p-6 text-center"
|
||||||
{displayStats.filesQueued}
|
style={{ backgroundColor: "#d8dab2" }}
|
||||||
</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' }}>
|
|
||||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
<div className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
{displayStats.filesSucceeded}
|
{importedCount}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700 font-medium">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
import React, { useMemo, ReactElement, useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
@@ -12,79 +11,130 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { format, fromUnixTime, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { useGetWantedComicsQuery } from "../../graphql/generated";
|
||||||
|
|
||||||
|
type FilterOption = "all" | "missingFiles";
|
||||||
|
|
||||||
|
interface SearchQuery {
|
||||||
|
query: Record<string, any>;
|
||||||
|
pagination: { size: number; from: number };
|
||||||
|
type: string;
|
||||||
|
trigger: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||||
|
{ value: "all", label: "All Comics" },
|
||||||
|
{ value: "missingFiles", label: "Missing Files" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component that tabulates the contents of the user's ThreeTwo Library.
|
* Library page component. Displays a paginated, searchable table of all comics
|
||||||
*
|
* in the collection, with an optional filter for comics with missing raw files.
|
||||||
* @component
|
|
||||||
* @example
|
|
||||||
* <Library />
|
|
||||||
*/
|
*/
|
||||||
export const Library = (): ReactElement => {
|
export const Library = (): ReactElement => {
|
||||||
// Default page state
|
const [searchParams] = useSearchParams();
|
||||||
// offset: 0
|
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
const [searchQuery, setSearchQuery] = useState({
|
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
||||||
query: {},
|
query: {},
|
||||||
pagination: {
|
pagination: { size: 25, from: 0 },
|
||||||
size: 25,
|
|
||||||
from: offset,
|
|
||||||
},
|
|
||||||
type: "all",
|
type: "all",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
/**
|
/** Fetches a page of issues from the search API. */
|
||||||
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
const fetchIssues = async (q: SearchQuery) => {
|
||||||
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
const { pagination, query, type } = q;
|
||||||
*/
|
|
||||||
const fetchIssues = async (searchQuery) => {
|
|
||||||
const { pagination, query, type } = searchQuery;
|
|
||||||
return await axios({
|
return await axios({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "http://localhost:3000/api/search/searchIssue",
|
url: "http://localhost:3000/api/search/searchIssue",
|
||||||
data: {
|
data: { query, pagination, type },
|
||||||
query,
|
|
||||||
pagination,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchIssues = (e) => {
|
const { data, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ["comics", searchQuery],
|
||||||
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
enabled: activeFilter === "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
||||||
|
{
|
||||||
|
paginationOptions: { limit: 25, page: 1 },
|
||||||
|
predicate: { "importStatus.isRawFileMissing": true },
|
||||||
|
},
|
||||||
|
{ enabled: activeFilter === "missingFiles" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: missingIdsData } = useGetWantedComicsQuery(
|
||||||
|
{
|
||||||
|
paginationOptions: { limit: 1000, page: 1 },
|
||||||
|
predicate: { "importStatus.isRawFileMissing": true },
|
||||||
|
},
|
||||||
|
{ enabled: activeFilter === "all" },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
||||||
|
const missingIdSet = useMemo(
|
||||||
|
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
||||||
|
[missingIdsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = data?.data;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
||||||
|
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
||||||
|
|
||||||
|
/** Triggers a search by volume name and resets pagination. */
|
||||||
|
const searchIssues = (e: any) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
setSearchQuery({
|
setSearchQuery({
|
||||||
query: {
|
query: { volumeName: e.search },
|
||||||
volumeName: e.search,
|
pagination: { size: 15, from: 0 },
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
size: 15,
|
|
||||||
from: 0,
|
|
||||||
},
|
|
||||||
type: "volumeName",
|
type: "volumeName",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
/** Advances to the next page of results. */
|
||||||
queryKey: ["comics", offset, searchQuery],
|
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||||
queryFn: () => fetchIssues(searchQuery),
|
if (!isPlaceholderData) {
|
||||||
placeholderData: keepPreviousData,
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const searchResults = data?.data;
|
|
||||||
// Programmatically navigate to comic detail
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const navigateToComicDetail = (row) => {
|
|
||||||
navigate(`/comic/details/${row.original._id}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComicInfoXML = (value) => {
|
/** Goes back to the previous page of results. */
|
||||||
return value.data ? (
|
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||||
|
let from = 0;
|
||||||
|
if (pageIndex === 2) {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||||
|
} else {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: { size: 15, from },
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComicInfoXML = (value: any) =>
|
||||||
|
value.data ? (
|
||||||
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
|
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
|
||||||
{/* Series Name */}
|
|
||||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||||
@@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
||||||
{/* Pages */}
|
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
||||||
@@ -103,7 +152,6 @@ export const Library = (): ReactElement => {
|
|||||||
Pages: {value.data.pagecount[0]}
|
Pages: {value.data.pagecount[0]}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/* Issue number */}
|
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||||
@@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
|
||||||
|
const missingFilesColumns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: "Missing Files",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "Status",
|
||||||
|
id: "missingStatus",
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
|
||||||
|
<i className="icon-[solar--file-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(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: "Comic Metadata",
|
header: "Comic Metadata",
|
||||||
footer: 1,
|
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "File Details",
|
header: "File Details",
|
||||||
id: "fileDetails",
|
id: "fileDetails",
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
accessorKey: "_source",
|
accessorKey: "_source",
|
||||||
cell: (info) => {
|
cell: (info: any) => {
|
||||||
return <MetadataPanel data={info.getValue()} />;
|
const source = info.getValue();
|
||||||
|
return (
|
||||||
|
<MetadataPanel
|
||||||
|
data={source}
|
||||||
|
isMissing={missingIdSet.has(info.row.original._id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "ComicInfo.xml",
|
header: "ComicInfo.xml",
|
||||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||||
cell: (info) =>
|
cell: (info: any) =>
|
||||||
!isEmpty(info.getValue()) ? (
|
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
||||||
<ComicInfoXML data={info.getValue()} />
|
|
||||||
) : null,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -150,102 +230,60 @@ export const Library = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Date of Import",
|
header: "Date of Import",
|
||||||
accessorKey: "_source.createdAt",
|
accessorKey: "_source.createdAt",
|
||||||
cell: (info) => {
|
cell: (info: any) =>
|
||||||
return !isNil(info.getValue()) ? (
|
!isNil(info.getValue()) ? (
|
||||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
||||||
{format(parseISO(info.getValue()), "h aaaa")}
|
{format(parseISO(info.getValue()), "h aaaa")}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Downloads",
|
header: "Downloads",
|
||||||
accessorKey: "_source.acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (info) => (
|
cell: (info: any) => (
|
||||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
||||||
<span className="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
|
||||||
DC++: {info.getValue().directconnect.downloads.length}
|
DC++: {info.getValue().directconnect.downloads.length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<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="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
|
||||||
<span className="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
|
||||||
Torrent: {info.getValue().torrent.length}
|
Torrent: {info.getValue().torrent.length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[missingIdSet],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const FilterDropdown = () => (
|
||||||
* Pagination control that fetches the next x (pageSize) items
|
<div className="relative">
|
||||||
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
<select
|
||||||
* @param {number} pageIndex
|
value={activeFilter}
|
||||||
* @param {number} pageSize
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
||||||
* @returns void
|
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
*
|
>
|
||||||
**/
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
<option key={opt.value} value={opt.value}>
|
||||||
if (!isPlaceholderData) {
|
{opt.label}
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
</option>
|
||||||
setSearchQuery({
|
))}
|
||||||
query: {},
|
</select>
|
||||||
pagination: {
|
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
||||||
size: 15,
|
</div>
|
||||||
from: pageSize * pageIndex + 1,
|
);
|
||||||
},
|
|
||||||
type: "all",
|
|
||||||
trigger: "libraryPage",
|
|
||||||
});
|
|
||||||
// setOffset(pageSize * pageIndex + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const isMissingFilter = activeFilter === "missingFiles";
|
||||||
* 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 (
|
return (
|
||||||
<div>
|
|
||||||
<section>
|
<section>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<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="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
@@ -254,7 +292,6 @@ export const Library = (): ReactElement => {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
Library
|
Library
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
Browse your comic book collection.
|
Browse your comic book collection.
|
||||||
</p>
|
</p>
|
||||||
@@ -262,22 +299,43 @@ export const Library = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{!isUndefined(searchResults?.hits) ? (
|
|
||||||
|
{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">
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<div>
|
|
||||||
<T2Table
|
<T2Table
|
||||||
totalPages={searchResults.hits.total.value}
|
totalPages={searchResults.hits.total.value}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
sourceData={searchResults?.hits.hits}
|
sourceData={searchResults?.hits.hits}
|
||||||
rowClickHandler={navigateToComicDetail}
|
rowClickHandler={navigateToComicDetail}
|
||||||
paginationHandlers={{
|
getRowClassName={(row) =>
|
||||||
nextPage,
|
missingIdSet.has(row.original._id)
|
||||||
previousPage,
|
? "bg-card-missing/40 hover:bg-card-missing/20"
|
||||||
}}
|
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
|
||||||
|
}
|
||||||
|
paginationHandlers={{ nextPage, previousPage }}
|
||||||
>
|
>
|
||||||
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
<div className="flex items-center gap-2">
|
||||||
</T2Table>
|
<FilterDropdown />
|
||||||
|
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
|
||||||
</div>
|
</div>
|
||||||
|
</T2Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto max-w-screen-xl mt-5">
|
<div className="mx-auto max-w-screen-xl mt-5">
|
||||||
@@ -287,29 +345,15 @@ export const Library = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
No comics were found in the library, Elasticsearch reports no
|
No comics were found in the library, Elasticsearch reports no indices. Try
|
||||||
indices. Try importing a few comics into the library and come
|
importing a few comics into the library and come back.
|
||||||
back.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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">
|
<FilterDropdown />
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ICardProps {
|
|||||||
children?: PropTypes.ReactNodeLike;
|
children?: PropTypes.ReactNodeLike;
|
||||||
borderColorClass?: string;
|
borderColorClass?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
|
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
||||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
cardContainerStyle?: React.CSSProperties;
|
cardContainerStyle?: React.CSSProperties;
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: React.CSSProperties;
|
||||||
@@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => {
|
|||||||
return "bg-card-uncompressed";
|
return "bg-card-uncompressed";
|
||||||
case "imported":
|
case "imported":
|
||||||
return "bg-card-imported";
|
return "bg-card-imported";
|
||||||
|
case "missing":
|
||||||
|
return "bg-card-missing";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -102,11 +104,22 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
case "vertical-2":
|
case "vertical-2":
|
||||||
return (
|
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"}`}>
|
<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"}`}>
|
||||||
|
<div className="relative">
|
||||||
|
{props.imageUrl ? (
|
||||||
<img
|
<img
|
||||||
alt="Home"
|
alt="Home"
|
||||||
src={props.imageUrl}
|
src={props.imageUrl}
|
||||||
className="rounded-t-md object-cover"
|
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 ? (
|
{props.title ? (
|
||||||
<div className="px-3 pt-3 mb-2">
|
<div className="px-3 pt-3 mb-2">
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
|||||||
import { find, isUndefined } from "lodash";
|
import { find, isUndefined } from "lodash";
|
||||||
|
|
||||||
interface IMetadatPanelProps {
|
interface IMetadatPanelProps {
|
||||||
value: any;
|
data: any;
|
||||||
children: any;
|
value?: any;
|
||||||
imageStyle: any;
|
children?: any;
|
||||||
titleStyle: any;
|
imageStyle?: any;
|
||||||
tagsStyle: any;
|
titleStyle?: any;
|
||||||
containerStyle: any;
|
tagsStyle?: any;
|
||||||
|
containerStyle?: any;
|
||||||
|
isMissing?: boolean;
|
||||||
}
|
}
|
||||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||||
|
const { isMissing = false } = props;
|
||||||
const {
|
const {
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
@@ -31,8 +34,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
{
|
{
|
||||||
name: "rawFileDetails",
|
name: "rawFileDetails",
|
||||||
content: () => (
|
content: () => (
|
||||||
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
|
<dl
|
||||||
<dt>
|
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>
|
<p className="text-sm sm:text-lg">{issueName}</p>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-xs sm:text-sm">
|
<dd className="text-xs sm:text-sm">
|
||||||
@@ -58,26 +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">
|
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
||||||
{/* File extension */}
|
{/* File extension */}
|
||||||
|
{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="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">
|
<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>
|
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||||
{rawFileDetails.mimeType}
|
{rawFileDetails.mimeType}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* size */}
|
{/* size */}
|
||||||
|
{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="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">
|
<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>
|
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||||
{prettyBytes(rawFileDetails.fileSize)}
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Uncompressed version available? */}
|
{/* Uncompressed version available? */}
|
||||||
{rawFileDetails.archive?.uncompressed && (
|
{rawFileDetails.archive?.uncompressed && (
|
||||||
@@ -177,7 +191,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
const metadataPanel = find(metadataContentPanel, {
|
const metadataPanel = find(metadataContentPanel, {
|
||||||
name: objectReference,
|
name: objectReference,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
||||||
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
||||||
@@ -188,7 +201,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
imageStyle={props.imageStyle}
|
imageStyle={props.imageStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">{metadataPanel.content()}</div>
|
<div className="flex-1">{metadataPanel?.content()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,69 +1,89 @@
|
|||||||
import React, { ReactElement, useMemo, useState } from "react";
|
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
Row,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
|
||||||
useReactTable,
|
useReactTable,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
interface T2TableProps {
|
/** Props for {@link T2Table}. */
|
||||||
sourceData?: unknown[];
|
interface T2TableProps<TData> {
|
||||||
|
/** Row data to render. */
|
||||||
|
sourceData?: TData[];
|
||||||
|
/** Total number of records across all pages, used for pagination display. */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
columns?: unknown[];
|
/** Column definitions (TanStack Table {@link ColumnDef} array). */
|
||||||
|
columns?: ColumnDef<TData>[];
|
||||||
|
/** Callbacks for navigating between pages. */
|
||||||
paginationHandlers?: {
|
paginationHandlers?: {
|
||||||
nextPage?(...args: unknown[]): unknown;
|
nextPage?(pageIndex: number, pageSize: number): void;
|
||||||
previousPage?(...args: unknown[]): unknown;
|
previousPage?(pageIndex: number, pageSize: number): void;
|
||||||
};
|
};
|
||||||
rowClickHandler?(...args: unknown[]): unknown;
|
/** Called with the TanStack row object when a row is clicked. */
|
||||||
children?: any;
|
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 {
|
* A paginated data table with a two-row sticky header.
|
||||||
sourceData,
|
*
|
||||||
columns,
|
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
|
||||||
paginationHandlers: { nextPage, previousPage },
|
* element placed immediately before the table. The second header row's `top`
|
||||||
totalPages,
|
* 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,
|
rowClickHandler,
|
||||||
} = tableOptions;
|
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>({
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 1,
|
pageIndex: 1,
|
||||||
pageSize: 15,
|
pageSize: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pagination = useMemo(
|
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||||
() => ({
|
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[pageIndex, pageSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/** Advances to the next page and notifies the parent. */
|
||||||
* Pagination control to move forward one page
|
|
||||||
* @returns void
|
|
||||||
*/
|
|
||||||
const goToNextPage = () => {
|
const goToNextPage = () => {
|
||||||
setPagination({
|
setPagination({ pageIndex: pageIndex + 1, pageSize });
|
||||||
pageIndex: pageIndex + 1,
|
nextPage?.(pageIndex, pageSize);
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
nextPage(pageIndex, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Goes back one page and notifies the parent. */
|
||||||
* Pagination control to move backward one page
|
|
||||||
* @returns void
|
|
||||||
**/
|
|
||||||
const goToPreviousPage = () => {
|
const goToPreviousPage = () => {
|
||||||
setPagination({
|
setPagination({ pageIndex: pageIndex - 1, pageSize });
|
||||||
pageIndex: pageIndex - 1,
|
previousPage?.(pageIndex, pageSize);
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
previousPage(pageIndex, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -72,20 +92,15 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
pageCount: sourceData.length ?? -1,
|
pageCount: sourceData.length ?? -1,
|
||||||
state: {
|
state: { pagination },
|
||||||
pagination,
|
|
||||||
},
|
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-fit">
|
<div className="container max-w-fit">
|
||||||
<div>
|
|
||||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||||
{/* Search bar */}
|
{children}
|
||||||
{tableOptions.children}
|
|
||||||
|
|
||||||
{/* Pagination controls */}
|
|
||||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||||
@@ -95,40 +110,44 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="inline-flex flex-row mt-3">
|
<div className="inline-flex flex-row mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => goToPreviousPage()}
|
onClick={goToPreviousPage}
|
||||||
disabled={pageIndex === 1}
|
disabled={pageIndex === 1}
|
||||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
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>
|
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
onClick={goToNextPage}
|
||||||
onClick={() => goToNextPage()}
|
|
||||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
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"></i>
|
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
<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) => (
|
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
|
||||||
{headerGroup.headers.map((header, index) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
colSpan={header.colSpan}
|
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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -136,11 +155,11 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{table.getRowModel().rows.map((row, rowIndex) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => rowClickHandler(row)}
|
onClick={() => rowClickHandler?.(row)}
|
||||||
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer"
|
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-3 py-2 align-top">
|
<td key={cell.id} className="px-3 py-2 align-top">
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ export type AcquisitionSourceInput = {
|
|||||||
wanted?: InputMaybe<Scalars['Boolean']['input']>;
|
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 = {
|
export type Archive = {
|
||||||
__typename?: 'Archive';
|
__typename?: 'Archive';
|
||||||
expandedPath?: Maybe<Scalars['String']['output']>;
|
expandedPath?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -52,6 +69,26 @@ export type AutoMergeSettingsInput = {
|
|||||||
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
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 = {
|
export type CanonicalMetadata = {
|
||||||
__typename?: 'CanonicalMetadata';
|
__typename?: 'CanonicalMetadata';
|
||||||
ageRating?: Maybe<MetadataField>;
|
ageRating?: Maybe<MetadataField>;
|
||||||
@@ -123,6 +160,11 @@ export type ComicConnection = {
|
|||||||
totalCount: Scalars['Int']['output'];
|
totalCount: Scalars['Int']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ComicVineMatchInput = {
|
||||||
|
volume: ComicVineVolumeRefInput;
|
||||||
|
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ComicVineResourceResponse = {
|
export type ComicVineResourceResponse = {
|
||||||
__typename?: 'ComicVineResourceResponse';
|
__typename?: 'ComicVineResourceResponse';
|
||||||
error: Scalars['String']['output'];
|
error: Scalars['String']['output'];
|
||||||
@@ -143,6 +185,24 @@ export type ComicVineSearchResult = {
|
|||||||
offset: Scalars['Int']['output'];
|
offset: Scalars['Int']['output'];
|
||||||
results: Array<SearchResultItem>;
|
results: Array<SearchResultItem>;
|
||||||
status_code: Scalars['Int']['output'];
|
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 {
|
export enum ConflictResolutionStrategy {
|
||||||
@@ -177,10 +237,22 @@ export type DirectConnectBundleInput = {
|
|||||||
size?: InputMaybe<Scalars['String']['input']>;
|
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 = {
|
export type DirectConnectInput = {
|
||||||
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
|
downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DirectConnectSettings = {
|
||||||
|
__typename?: 'DirectConnectSettings';
|
||||||
|
client?: Maybe<DirectConnectClient>;
|
||||||
|
};
|
||||||
|
|
||||||
export type DirectorySize = {
|
export type DirectorySize = {
|
||||||
__typename?: 'DirectorySize';
|
__typename?: 'DirectorySize';
|
||||||
fileCount: Scalars['Int']['output'];
|
fileCount: Scalars['Int']['output'];
|
||||||
@@ -234,6 +306,37 @@ export type GetVolumesInput = {
|
|||||||
volumeURI: Scalars['String']['input'];
|
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 = {
|
export type ImageUrls = {
|
||||||
__typename?: 'ImageUrls';
|
__typename?: 'ImageUrls';
|
||||||
icon_url?: Maybe<Scalars['String']['output']>;
|
icon_url?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -303,6 +406,7 @@ export type ImportStatistics = {
|
|||||||
export type ImportStats = {
|
export type ImportStats = {
|
||||||
__typename?: 'ImportStats';
|
__typename?: 'ImportStats';
|
||||||
alreadyImported: Scalars['Int']['output'];
|
alreadyImported: Scalars['Int']['output'];
|
||||||
|
missingFiles: Scalars['Int']['output'];
|
||||||
newFiles: Scalars['Int']['output'];
|
newFiles: Scalars['Int']['output'];
|
||||||
percentageImported: Scalars['String']['output'];
|
percentageImported: Scalars['String']['output'];
|
||||||
totalLocalFiles: Scalars['Int']['output'];
|
totalLocalFiles: Scalars['Int']['output'];
|
||||||
@@ -311,6 +415,7 @@ export type ImportStats = {
|
|||||||
export type ImportStatus = {
|
export type ImportStatus = {
|
||||||
__typename?: 'ImportStatus';
|
__typename?: 'ImportStatus';
|
||||||
isImported?: Maybe<Scalars['Boolean']['output']>;
|
isImported?: Maybe<Scalars['Boolean']['output']>;
|
||||||
|
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
|
||||||
matchedResult?: Maybe<MatchedResult>;
|
matchedResult?: Maybe<MatchedResult>;
|
||||||
tagged?: Maybe<Scalars['Boolean']['output']>;
|
tagged?: Maybe<Scalars['Boolean']['output']>;
|
||||||
};
|
};
|
||||||
@@ -421,6 +526,7 @@ export type LocgMetadataInput = {
|
|||||||
export type LibraryStatistics = {
|
export type LibraryStatistics = {
|
||||||
__typename?: 'LibraryStatistics';
|
__typename?: 'LibraryStatistics';
|
||||||
comicDirectorySize: DirectorySize;
|
comicDirectorySize: DirectorySize;
|
||||||
|
comicsMissingFiles: Scalars['Int']['output'];
|
||||||
statistics: Array<StatisticsFacet>;
|
statistics: Array<StatisticsFacet>;
|
||||||
totalDocuments: Scalars['Int']['output'];
|
totalDocuments: Scalars['Int']['output'];
|
||||||
};
|
};
|
||||||
@@ -515,6 +621,10 @@ export type Mutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
/** Placeholder for future mutations */
|
/** Placeholder for future mutations */
|
||||||
_empty?: Maybe<Scalars['String']['output']>;
|
_empty?: Maybe<Scalars['String']['output']>;
|
||||||
|
/** Add a torrent to qBittorrent */
|
||||||
|
addTorrent?: Maybe<AddTorrentResult>;
|
||||||
|
analyzeImage: ImageAnalysisResult;
|
||||||
|
applyComicVineMatch: Comic;
|
||||||
bulkResolveMetadata: Array<Comic>;
|
bulkResolveMetadata: Array<Comic>;
|
||||||
forceCompleteSession: ForceCompleteResult;
|
forceCompleteSession: ForceCompleteResult;
|
||||||
importComic: ImportComicResult;
|
importComic: ImportComicResult;
|
||||||
@@ -524,11 +634,28 @@ export type Mutation = {
|
|||||||
setMetadataField: Comic;
|
setMetadataField: Comic;
|
||||||
startIncrementalImport: IncrementalImportResult;
|
startIncrementalImport: IncrementalImportResult;
|
||||||
startNewImport: ImportJobResult;
|
startNewImport: ImportJobResult;
|
||||||
|
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
|
||||||
updateSourcedMetadata: Comic;
|
updateSourcedMetadata: Comic;
|
||||||
updateUserPreferences: UserPreferences;
|
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 = {
|
export type MutationBulkResolveMetadataArgs = {
|
||||||
comicIds: Array<Scalars['ID']['input']>;
|
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 = {
|
export type MutationUpdateSourcedMetadataArgs = {
|
||||||
comicId: Scalars['ID']['input'];
|
comicId: Scalars['ID']['input'];
|
||||||
metadata: Scalars['String']['input'];
|
metadata: Scalars['String']['input'];
|
||||||
@@ -627,6 +761,17 @@ export type Provenance = {
|
|||||||
url?: Maybe<Scalars['String']['output']>;
|
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 = {
|
export type Publisher = {
|
||||||
__typename?: 'Publisher';
|
__typename?: 'Publisher';
|
||||||
api_detail_url?: Maybe<Scalars['String']['output']>;
|
api_detail_url?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -642,7 +787,9 @@ export type PublisherStats = {
|
|||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
_empty?: Maybe<Scalars['String']['output']>;
|
||||||
analyzeMetadataConflicts: Array<MetadataConflict>;
|
analyzeMetadataConflicts: Array<MetadataConflict>;
|
||||||
|
bundles: Array<Bundle>;
|
||||||
comic?: Maybe<Comic>;
|
comic?: Maybe<Comic>;
|
||||||
comics: ComicConnection;
|
comics: ComicConnection;
|
||||||
/** Fetch resource from Metron API */
|
/** Fetch resource from Metron API */
|
||||||
@@ -663,13 +810,18 @@ export type Query = {
|
|||||||
getVolume: VolumeDetailResponse;
|
getVolume: VolumeDetailResponse;
|
||||||
/** Get weekly pull list from League of Comic Geeks */
|
/** Get weekly pull list from League of Comic Geeks */
|
||||||
getWeeklyPullList: MetadataPullListResponse;
|
getWeeklyPullList: MetadataPullListResponse;
|
||||||
|
hubs: Array<Hub>;
|
||||||
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
|
previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
|
||||||
/** Search ComicVine for volumes, issues, characters, etc. */
|
/** Search ComicVine for volumes, issues, characters, etc. */
|
||||||
searchComicVine: ComicVineSearchResult;
|
searchComicVine: ComicVineSearchResult;
|
||||||
searchIssue: SearchIssueResult;
|
searchIssue: SearchIssueResult;
|
||||||
|
searchTorrents: Array<TorrentSearchResult>;
|
||||||
|
settings?: Maybe<AppSettings>;
|
||||||
|
torrentJobs?: Maybe<TorrentJob>;
|
||||||
userPreferences?: Maybe<UserPreferences>;
|
userPreferences?: Maybe<UserPreferences>;
|
||||||
/** Advanced volume-based search with scoring and filtering */
|
/** Advanced volume-based search with scoring and filtering */
|
||||||
volumeBasedSearch: VolumeBasedSearchResponse;
|
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 = {
|
export type QueryComicArgs = {
|
||||||
id: Scalars['ID']['input'];
|
id: Scalars['ID']['input'];
|
||||||
};
|
};
|
||||||
@@ -733,6 +891,11 @@ export type QueryGetWeeklyPullListArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryHubsArgs = {
|
||||||
|
host: HostInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryPreviewCanonicalMetadataArgs = {
|
export type QueryPreviewCanonicalMetadataArgs = {
|
||||||
comicId: Scalars['ID']['input'];
|
comicId: Scalars['ID']['input'];
|
||||||
preferences?: InputMaybe<UserPreferencesInput>;
|
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 = {
|
export type QueryUserPreferencesArgs = {
|
||||||
userId?: InputMaybe<Scalars['String']['input']>;
|
userId?: InputMaybe<Scalars['String']['input']>;
|
||||||
};
|
};
|
||||||
@@ -760,6 +938,12 @@ export type QueryVolumeBasedSearchArgs = {
|
|||||||
input: VolumeSearchInput;
|
input: VolumeSearchInput;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryWalkFoldersArgs = {
|
||||||
|
basePathToWalk: Scalars['String']['input'];
|
||||||
|
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||||
|
};
|
||||||
|
|
||||||
export type RawFileDetails = {
|
export type RawFileDetails = {
|
||||||
__typename?: 'RawFileDetails';
|
__typename?: 'RawFileDetails';
|
||||||
archive?: Maybe<Archive>;
|
archive?: Maybe<Archive>;
|
||||||
@@ -938,6 +1122,24 @@ export type TeamCredit = {
|
|||||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
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 = {
|
export type UserPreferences = {
|
||||||
__typename?: 'UserPreferences';
|
__typename?: 'UserPreferences';
|
||||||
autoMerge: AutoMergeSettings;
|
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<{
|
export type GetWantedComicsQueryVariables = Exact<{
|
||||||
paginationOptions: PaginationOptionsInput;
|
paginationOptions: PaginationOptionsInput;
|
||||||
@@ -1101,7 +1303,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
|
|||||||
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
|
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<{
|
export type GetWeeklyPullListQueryVariables = Exact<{
|
||||||
input: WeeklyPullListInput;
|
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<{
|
export type StartNewImportMutationVariables = Exact<{
|
||||||
sessionId: Scalars['String']['input'];
|
sessionId: Scalars['String']['input'];
|
||||||
@@ -1524,6 +1726,9 @@ export const GetRecentComicsDocument = `
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
importStatus {
|
||||||
|
isRawFileMissing
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -1739,8 +1944,10 @@ export const GetLibraryStatisticsDocument = `
|
|||||||
query GetLibraryStatistics {
|
query GetLibraryStatistics {
|
||||||
getLibraryStatistics {
|
getLibraryStatistics {
|
||||||
totalDocuments
|
totalDocuments
|
||||||
|
comicsMissingFiles
|
||||||
comicDirectorySize {
|
comicDirectorySize {
|
||||||
fileCount
|
fileCount
|
||||||
|
totalSizeInGB
|
||||||
}
|
}
|
||||||
statistics {
|
statistics {
|
||||||
fileTypes {
|
fileTypes {
|
||||||
@@ -1874,6 +2081,7 @@ export const GetImportStatisticsDocument = `
|
|||||||
totalLocalFiles
|
totalLocalFiles
|
||||||
alreadyImported
|
alreadyImported
|
||||||
newFiles
|
newFiles
|
||||||
|
missingFiles
|
||||||
percentageImported
|
percentageImported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ query GetRecentComics($limit: Int) {
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
importStatus {
|
||||||
|
isRawFileMissing
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -176,8 +179,10 @@ query GetVolumeGroups {
|
|||||||
query GetLibraryStatistics {
|
query GetLibraryStatistics {
|
||||||
getLibraryStatistics {
|
getLibraryStatistics {
|
||||||
totalDocuments
|
totalDocuments
|
||||||
|
comicsMissingFiles
|
||||||
comicDirectorySize {
|
comicDirectorySize {
|
||||||
fileCount
|
fileCount
|
||||||
|
totalSizeInGB
|
||||||
}
|
}
|
||||||
statistics {
|
statistics {
|
||||||
fileTypes {
|
fileTypes {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ query GetImportStatistics($directoryPath: String) {
|
|||||||
totalLocalFiles
|
totalLocalFiles
|
||||||
alreadyImported
|
alreadyImported
|
||||||
newFiles
|
newFiles
|
||||||
|
missingFiles
|
||||||
percentageImported
|
percentageImported
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
// Track if we've received completion events
|
// Track if we've received completion events
|
||||||
const completionEventReceived = useRef(false);
|
const completionEventReceived = useRef(false);
|
||||||
const queueDrainedEventReceived = useRef(false);
|
const queueDrainedEventReceived = useRef(false);
|
||||||
|
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
|
||||||
|
// Prevents a stale "running" DB session from showing as active on hard refresh.
|
||||||
|
const sessionStartedEventReceived = useRef(false);
|
||||||
|
|
||||||
// Query active import session - NO POLLING, only refetch on Socket.IO events
|
// Query active import session - polls every 3s as a fallback when a session is
|
||||||
|
// active (e.g. tab re-opened mid-import and socket events were missed)
|
||||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: true,
|
||||||
refetchInterval: false, // NO POLLING
|
refetchInterval: (query) => {
|
||||||
|
const s = (query.state.data as any)?.getActiveImportSession;
|
||||||
|
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
||||||
|
? 3000
|
||||||
|
: false;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
|
|
||||||
// Case 3: Check if session is actually running/active
|
// Case 3: Check if session is actually running/active
|
||||||
if (status === "running" || status === "active" || status === "processing") {
|
if (status === "running" || status === "active" || status === "processing") {
|
||||||
// Check if there's actual progress happening
|
|
||||||
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
|
|
||||||
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
||||||
|
// Only treat as "just started" if the started event fired in this browser session.
|
||||||
|
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
|
||||||
|
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
||||||
|
|
||||||
// Only treat as active if there's progress OR it just started
|
// No in-session event AND no actual progress → stale unclosed session from a previous run.
|
||||||
if (hasProgress && hasQueuedWork) {
|
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
|
||||||
|
const likelyStale = !sessionStartedEventReceived.current
|
||||||
|
&& stats.filesProcessed === 0
|
||||||
|
&& stats.filesSucceeded === 0;
|
||||||
|
|
||||||
|
if ((hasQueuedWork || justStarted) && !likelyStale) {
|
||||||
return {
|
return {
|
||||||
status: "running",
|
status: "running",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Session says "running" but no progress - likely stuck/stale
|
// Session says "running" but all files processed — likely a stale session
|
||||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
|
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||||
return {
|
return {
|
||||||
status: "idle",
|
status: "idle",
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -243,10 +258,11 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionStarted = () => {
|
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
|
// Reset completion flags when new session starts
|
||||||
completionEventReceived.current = false;
|
completionEventReceived.current = false;
|
||||||
queueDrainedEventReceived.current = false;
|
queueDrainedEventReceived.current = false;
|
||||||
|
sessionStartedEventReceived.current = true;
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,12 +275,14 @@ export const useImportSessionStatus = (): ImportSessionState => {
|
|||||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||||
|
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||||
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||||
|
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
||||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||||
};
|
};
|
||||||
}, [getSocket, refetch]);
|
}, [getSocket, refetch]);
|
||||||
|
|||||||
@@ -44,21 +44,23 @@ export const determineCoverFile = (data): any => {
|
|||||||
};
|
};
|
||||||
// comicvine
|
// comicvine
|
||||||
if (!isEmpty(data.comicvine)) {
|
if (!isEmpty(data.comicvine)) {
|
||||||
coverFile.comicvine.url = data?.comicvine?.image.small_url;
|
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
|
||||||
coverFile.comicvine.issueName = data.comicvine?.name;
|
coverFile.comicvine.issueName = data.comicvine?.name;
|
||||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||||
}
|
}
|
||||||
// rawFileDetails
|
// rawFileDetails
|
||||||
if (!isEmpty(data.rawFileDetails)) {
|
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
|
||||||
const encodedFilePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||||
);
|
);
|
||||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||||
|
} else if (!isEmpty(data.rawFileDetails)) {
|
||||||
|
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||||
}
|
}
|
||||||
// wanted
|
// wanted
|
||||||
|
|
||||||
if (!isUndefined(data.locg)) {
|
if (!isNil(data.locg)) {
|
||||||
coverFile.locg.url = data.locg.cover;
|
coverFile.locg.url = data.locg.cover;
|
||||||
coverFile.locg.issueName = data.locg.name;
|
coverFile.locg.issueName = data.locg.name;
|
||||||
coverFile.locg.publisher = data.locg.publisher;
|
coverFile.locg.publisher = data.locg.publisher;
|
||||||
@@ -66,14 +68,15 @@ export const determineCoverFile = (data): any => {
|
|||||||
|
|
||||||
const result = filter(coverFile, (item) => item.url !== "");
|
const result = filter(coverFile, (item) => item.url !== "");
|
||||||
|
|
||||||
if (result.length > 1) {
|
if (result.length >= 1) {
|
||||||
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
||||||
if (!isUndefined(highestPriorityCoverFile)) {
|
if (!isUndefined(highestPriorityCoverFile)) {
|
||||||
return highestPriorityCoverFile;
|
return highestPriorityCoverFile;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return result[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cover URL available — return rawFile entry so the name is still shown
|
||||||
|
return coverFile.rawFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const determineExternalMetadata = (
|
export const determineExternalMetadata = (
|
||||||
@@ -85,8 +88,8 @@ export const determineExternalMetadata = (
|
|||||||
case "comicvine":
|
case "comicvine":
|
||||||
return {
|
return {
|
||||||
coverURL:
|
coverURL:
|
||||||
source.comicvine?.image.small_url ||
|
source.comicvine?.image?.small_url ||
|
||||||
source.comicvine.volumeInformation?.image.small_url,
|
source.comicvine?.volumeInformation?.image?.small_url,
|
||||||
issue: source.comicvine.name,
|
issue: source.comicvine.name,
|
||||||
icon: "cvlogo.svg",
|
icon: "cvlogo.svg",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ module.exports = {
|
|||||||
scraped: "#b8edbc",
|
scraped: "#b8edbc",
|
||||||
uncompressed: "#FFF3E0",
|
uncompressed: "#FFF3E0",
|
||||||
imported: "#d8dab0",
|
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
16
yarn.lock
16
yarn.lock
@@ -9255,10 +9255,10 @@ sass@^1.97.3:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@parcel/watcher" "^2.4.1"
|
"@parcel/watcher" "^2.4.1"
|
||||||
|
|
||||||
sax@^1.5.0:
|
sax@^1.4.1:
|
||||||
version "1.5.0"
|
version "1.4.4"
|
||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.5.0.tgz#b5549b671069b7aa392df55ec7574cf411179eb8"
|
resolved "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz"
|
||||||
integrity sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==
|
integrity sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==
|
||||||
|
|
||||||
saxes@^6.0.0:
|
saxes@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
@@ -9778,9 +9778,9 @@ supports-preserve-symlinks-flag@^1.0.0:
|
|||||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
svgo@^4.0.0:
|
svgo@^4.0.0:
|
||||||
version "4.0.1"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-4.0.1.tgz#c82dacd04ee9f1d55cd4e0b7f9a214c86670e3ee"
|
resolved "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz"
|
||||||
integrity sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==
|
integrity sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==
|
||||||
dependencies:
|
dependencies:
|
||||||
commander "^11.1.0"
|
commander "^11.1.0"
|
||||||
css-select "^5.1.0"
|
css-select "^5.1.0"
|
||||||
@@ -9788,7 +9788,7 @@ svgo@^4.0.0:
|
|||||||
css-what "^6.1.0"
|
css-what "^6.1.0"
|
||||||
csso "^5.0.5"
|
csso "^5.0.5"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
sax "^1.5.0"
|
sax "^1.4.1"
|
||||||
|
|
||||||
swap-case@^2.0.2:
|
swap-case@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user