🛠 changed import to account for graphql

This commit is contained in:
2026-02-24 13:39:50 -05:00
parent dc014a08ce
commit ea66419f33
5 changed files with 457 additions and 191 deletions

View File

@@ -5,8 +5,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils"; import { difference } from "../../shared/utils/object.utils";
@@ -15,44 +14,104 @@ import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints"; import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
comicObjectId: any; comicObjectId: string;
comicObject: any; comicObject: any;
settings: any; settings: any;
} }
interface AirDCPPConfig {
protocol: string;
hostname: string;
username: string;
password: string;
}
interface SearchResult {
id: string;
name: string;
type: {
id: string;
str: string;
};
size: number;
slots: {
total: number;
free: number;
};
users: {
user: {
nicks: string;
flags: string[];
};
};
dupe?: any;
}
interface SearchInstanceData {
id: number;
owner: string;
expires_in: number;
}
interface SearchInfo {
query: {
pattern: string;
extensions: string[];
file_type: string;
};
}
interface Hub {
hub_url: string;
identity: {
name: string;
};
value: string;
}
interface SearchFormValues {
issueName: string;
}
export const AcquisitionPanel = ( export const AcquisitionPanel = (
props: IAcquisitionPanelProps, props: IAcquisitionPanelProps,
): ReactElement => { ): ReactElement => {
const socketRef = useRef<Socket>(); const socketRef = useRef<Socket>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [dcppQuery, setDcppQuery] = useState({}); const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null);
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]); const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false); const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({}); const [isSearching, setIsSearching] = useState(false);
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({}); const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<SearchInstanceData | null>(null);
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<SearchInfo | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);
const { comicObjectId } = props; const { comicObjectId } = props;
const issueName = props.query.issue.name || ""; const issueName = props.query.issue.name || "";
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Search timeout duration in milliseconds (30 seconds)
const SEARCH_TIMEOUT_MS = 30000;
useEffect(() => { useEffect(() => {
const socket = useStore.getState().getSocket("manual"); const socket = useStore.getState().getSocket("manual");
socketRef.current = socket; socketRef.current = socket;
// --- Handlers --- // --- Handlers ---
const handleResultAdded = ({ result }: any) => { const handleResultAdded = ({ result }: { result: SearchResult }) => {
setAirDCPPSearchResults((prev) => setAirDCPPSearchResults((prev) =>
prev.some((r) => r.id === result.id) ? prev : [...prev, result], prev.some((r) => r.id === result.id) ? prev : [...prev, result],
); );
}; };
const handleResultUpdated = ({ result }: any) => { const handleResultUpdated = ({ result }: { result: SearchResult }) => {
setAirDCPPSearchResults((prev) => { setAirDCPPSearchResults((prev) => {
const idx = prev.findIndex((r) => r.id === result.id); const idx = prev.findIndex((r) => r.id === result.id);
if (idx === -1) return prev; if (idx === -1) return prev;
@@ -63,45 +122,86 @@ export const AcquisitionPanel = (
}); });
}; };
const handleSearchInitiated = (data: any) => { const handleSearchInitiated = (data: { instance: SearchInstanceData }) => {
setAirDCPPSearchInstance(data.instance); setAirDCPPSearchInstance(data.instance);
setIsSearching(true);
setSearchError(null);
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Set a timeout to stop searching after SEARCH_TIMEOUT_MS
searchTimeoutRef.current = setTimeout(() => {
setIsSearching(false);
console.log(`Search timeout reached after ${SEARCH_TIMEOUT_MS / 1000} seconds`);
}, SEARCH_TIMEOUT_MS);
}; };
const handleSearchesSent = (data: any) => { const handleSearchesSent = (data: { searchInfo: SearchInfo }) => {
setAirDCPPSearchInfo(data.searchInfo); setAirDCPPSearchInfo(data.searchInfo);
}; };
const handleSearchError = (error: { message: string }) => {
setSearchError(error.message || "Search failed");
setIsSearching(false);
// Clear timeout on error
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
};
const handleSearchCompleted = () => {
setIsSearching(false);
// Clear timeout when search completes
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
};
// --- Subscribe once --- // --- Subscribe once ---
socket.on("searchResultAdded", handleResultAdded); socket.on("searchResultAdded", handleResultAdded);
socket.on("searchResultUpdated", handleResultUpdated); socket.on("searchResultUpdated", handleResultUpdated);
socket.on("searchInitiated", handleSearchInitiated); socket.on("searchInitiated", handleSearchInitiated);
socket.on("searchesSent", handleSearchesSent); socket.on("searchesSent", handleSearchesSent);
socket.on("searchError", handleSearchError);
socket.on("searchCompleted", handleSearchCompleted);
return () => { return () => {
socket.off("searchResultAdded", handleResultAdded); socket.off("searchResultAdded", handleResultAdded);
socket.off("searchResultUpdated", handleResultUpdated); socket.off("searchResultUpdated", handleResultUpdated);
socket.off("searchInitiated", handleSearchInitiated); socket.off("searchInitiated", handleSearchInitiated);
socket.off("searchesSent", handleSearchesSent); socket.off("searchesSent", handleSearchesSent);
// if you want to fully close the socket: socket.off("searchError", handleSearchError);
// useStore.getState().disconnectSocket("/manual"); socket.off("searchCompleted", handleSearchCompleted);
// Clean up timeout on unmount
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
}; };
}, []); }, [SEARCH_TIMEOUT_MS]);
const { const {
data: settings, data: settings,
isLoading, isLoading: isLoadingSettings,
isError, isError: isSettingsError,
} = useQuery({ } = useQuery({
queryKey: ["settings"], queryKey: ["settings"],
queryFn: async () => queryFn: async () =>
await axios({ await axios({
url: "http://localhost:3000/api/settings/getAllSettings", url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
method: "GET", method: "GET",
}), }),
}); });
const { data: hubs } = useQuery({ const { data: hubs, isLoading: isLoadingHubs } = useQuery({
queryKey: ["hubs"], queryKey: ["hubs", settings?.data.directConnect?.client?.host],
queryFn: async () => queryFn: async () =>
await axios({ await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`, url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
@@ -110,45 +210,72 @@ export const AcquisitionPanel = (
host: settings?.data.directConnect?.client?.host, host: settings?.data.directConnect?.client?.host,
}, },
}), }),
enabled: !isEmpty(settings?.data.directConnect?.client?.host), enabled: !!settings?.data?.directConnect?.client?.host,
}); });
// Get AirDC++ config from settings
const airDCPPConfig: AirDCPPConfig | null = settings?.data?.directConnect?.client
? {
protocol: settings.data.directConnect.client.protocol || "ws",
hostname: typeof settings.data.directConnect.client.host === 'string'
? settings.data.directConnect.client.host
: `${settings.data.directConnect.client.host?.hostname || 'localhost'}:${settings.data.directConnect.client.host?.port || '5600'}`,
username: settings.data.directConnect.client.username || "admin",
password: settings.data.directConnect.client.password || "password",
}
: null;
useEffect(() => { useEffect(() => {
const dcppSearchQuery = { if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
query: { const dcppSearchQuery = {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`, query: {
extensions: ["cbz", "cbr", "cb7"], pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
}, extensions: ["cbz", "cbr", "cb7"],
hub_urls: map(hubs?.data, (item) => item.value), },
priority: 5, hub_urls: map(hubs.data, (item) => item.value),
}; priority: 5,
setDcppQuery(dcppSearchQuery); };
setDcppQuery(dcppSearchQuery as any);
}
}, [hubs, sanitizedIssueName]); }, [hubs, sanitizedIssueName]);
const search = async (searchData: any) => { const search = async (searchData: any) => {
if (!airDCPPConfig) {
setSearchError("AirDC++ configuration not found in settings");
return;
}
if (!socketRef.current) {
setSearchError("Socket connection not available");
return;
}
setAirDCPPSearchResults([]); setAirDCPPSearchResults([]);
socketRef.current?.emit("call", "socket.search", { setIsSearching(true);
setSearchError(null);
socketRef.current.emit("call", "socket.search", {
query: searchData, query: searchData,
namespace: "/manual", namespace: "/manual",
config: { config: airDCPPConfig,
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
}); });
}; };
const download = async ( const download = async (
searchInstanceId: Number, searchInstanceId: number,
resultId: String, resultId: string,
comicObjectId: String, comicObjectId: string,
name: String, name: string,
size: Number, size: number,
type: any, type: SearchResult["type"],
config: any, config: AirDCPPConfig,
): Promise<void> => { ): Promise<void> => {
socketRef.current?.emit( if (!socketRef.current) {
console.error("Socket connection not available");
return;
}
socketRef.current.emit(
"call", "call",
"socket.download", "socket.download",
{ {
@@ -160,17 +287,27 @@ export const AcquisitionPanel = (
type, type,
config, config,
}, },
(data: any) => console.log(data), (data: any) => console.log("Download initiated:", data),
); );
}; };
const getDCPPSearchResults = async (searchQuery) => { const getDCPPSearchResults = async (searchQuery: SearchFormValues) => {
if (!searchQuery.issueName || searchQuery.issueName.trim() === "") {
setSearchError("Please enter a search term");
return;
}
if (!hubs?.data || !Array.isArray(hubs.data) || hubs.data.length === 0) {
setSearchError("No hubs configured");
return;
}
const manualQuery = { const manualQuery = {
query: { query: {
pattern: `${searchQuery.issueName}`, pattern: `${searchQuery.issueName.trim()}`,
extensions: ["cbz", "cbr", "cb7"], extensions: ["cbz", "cbr", "cb7"],
}, },
hub_urls: [hubs?.data[0].hub_url], hub_urls: [hubs.data[0].hub_url],
priority: 5, priority: 5,
}; };
@@ -180,7 +317,12 @@ export const AcquisitionPanel = (
return ( return (
<> <>
<div className="mt-5 mb-3"> <div className="mt-5 mb-3">
{!isEmpty(hubs?.data) ? ( {isLoadingSettings || isLoadingHubs ? (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin" />
Loading configuration...
</div>
) : !isEmpty(hubs?.data) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
@@ -200,20 +342,31 @@ export const AcquisitionPanel = (
{...input} {...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name" placeholder="Type an issue/volume name"
disabled={isSearching}
/> />
<button <button
className="sm:mt-0 min-w-fit rounded-r-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" className="sm:mt-0 min-w-fit rounded-r-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 disabled:opacity-50 disabled:cursor-not-allowed"
type="submit" type="submit"
disabled={isSearching}
> >
<div className="flex flex-row"> <div className="flex flex-row items-center">
Search DC++ {isSearching ? (
<div className="h-5 w-5 ml-2"> <>
<img <i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" />
src="/src/client/assets/img/airdcpp_logo.svg" Searching...
className="h-5 w-5" </>
/> ) : (
</div> <>
Search DC++
<div className="h-5 w-5 ml-2">
<img
src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</>
)}
</div> </div>
</button> </button>
</div> </div>
@@ -234,26 +387,36 @@ export const AcquisitionPanel = (
</article> </article>
)} )}
</div> </div>
{/* Search Error Display */}
{searchError && (
<article
role="alert"
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-red-500 bg-red-50 p-4 dark:border-s-4 dark:border-red-600 dark:bg-red-300 dark:text-slate-600"
>
<strong>Error:</strong> {searchError}
</article>
)}
{/* configured hub */} {/* configured hub */}
{!isEmpty(hubs?.data) && ( {!isEmpty(hubs?.data) && hubs?.data[0] && (
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300"> <span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
<span className="pr-1 pt-1"> <span className="pr-1 pt-1">
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
</span> </span>
{hubs && hubs?.data[0].hub_url} {hubs.data[0].hub_url}
</span> </span>
)} )}
{/* AirDC++ search instance details */} {/* AirDC++ search instance details */}
{!isNil(airDCPPSearchInstance) && {airDCPPSearchInstance &&
!isEmpty(airDCPPSearchInfo) && airDCPPSearchInfo &&
!isNil(hubs) && ( hubs?.data && (
<div className="flex flex-row gap-3 my-5 font-hasklig"> <div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700"> <div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl> <dl>
<dt> <dt>
<div className="mb-1"> <div className="mb-1">
{hubs?.data.map((value, idx: string) => ( {hubs.data.map((value: Hub, idx: number) => (
<span className="tag is-warning" key={idx}> <span className="tag is-warning" key={idx}>
{value.identity.name} {value.identity.name}
</span> </span>
@@ -293,7 +456,7 @@ export const AcquisitionPanel = (
{/* AirDC++ results */} {/* AirDC++ results */}
<div className=""> <div className="">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( {airDCPPSearchResults.length > 0 ? (
<div className="overflow-x-auto max-w-full mt-6"> <div className="overflow-x-auto max-w-full mt-6">
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100"> <table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
<thead> <thead>
@@ -345,9 +508,9 @@ export const AcquisitionPanel = (
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i> <i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
{users.user.nicks} {users.user.nicks}
</span> </span>
{users.user.flags.map((flag, idx) => ( {users.user.flags.map((flag: string, flagIdx: number) => (
<span <span
key={idx} key={flagIdx}
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900" className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
> >
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i> <i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
@@ -378,23 +541,21 @@ export const AcquisitionPanel = (
{/* ACTIONS */} {/* ACTIONS */}
<td className="px-2 py-3"> <td className="px-2 py-3">
<button <button
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent" className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => onClick={() => {
download( if (airDCPPSearchInstance && airDCPPConfig) {
airDCPPSearchInstance.id, download(
id, airDCPPSearchInstance.id,
comicObjectId, id,
name, comicObjectId,
size, name,
type, size,
{ type,
protocol: `ws`, airDCPPConfig,
hostname: `192.168.1.119:5600`, );
username: `admin`, }
password: `password`, }}
}, disabled={!airDCPPSearchInstance || !airDCPPConfig}
)
}
> >
Download Download
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i> <i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
@@ -406,7 +567,7 @@ export const AcquisitionPanel = (
</tbody> </tbody>
</table> </table>
</div> </div>
) : ( ) : !isSearching ? (
<div className=""> <div className="">
<article <article
role="alert" role="alert"
@@ -432,6 +593,11 @@ export const AcquisitionPanel = (
</div> </div>
</article> </article>
</div> </div>
) : (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-6 p-4">
<i className="icon-[solar--refresh-bold-duotone] h-6 w-6 animate-spin" />
Searching...
</div>
)} )}
</div> </div>
</> </>

View File

@@ -6,6 +6,7 @@ import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header"; import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
type WantedComicsListProps = { type WantedComicsListProps = {
comics: any; comics: any;
@@ -16,107 +17,126 @@ export const WantedComicsList = ({
}: WantedComicsListProps): ReactElement => { }: WantedComicsListProps): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return ( return (
<> <div>
<Header <Header
headerContent="Wanted Comics" headerContent="Wanted Comics"
subHeaderContent="Comics marked as wanted from various sources" subHeaderContent="Comics marked as wanted from various sources"
iconClassNames="fa-solid fa-binoculars mr-2" iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"} link={"/wanted"}
/> />
<div className="grid grid-cols-5 gap-6 mt-3"> <div className="overflow-hidden -mr-4 sm:-mr-8 lg:-mr-16 xl:-mr-20 2xl:-mr-24 mt-3">
{map( <div className="overflow-hidden" ref={emblaRef}>
comics, <div className="flex">
({ {map(
_id, comics,
rawFileDetails, (
sourcedMetadata: { comicvine, comicInfo, locg }, {
wanted, _id,
}) => { rawFileDetails,
const isComicBookMetadataAvailable = !isUndefined(comicvine); sourcedMetadata: { comicvine, comicInfo, locg },
const consolidatedComicMetadata = { wanted,
rawFileDetails, },
comicvine, idx,
comicInfo, ) => {
locg, const isComicBookMetadataAvailable = !isUndefined(comicvine);
}; const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const { const {
issueName, issueName,
url, url,
publisher = null, publisher = null,
} = determineCoverFile(consolidatedComicMetadata); } = determineCoverFile(consolidatedComicMetadata);
const titleElement = ( const titleElement = (
<Link to={"/comic/details/" + _id}> <Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)} {ellipsize(issueName, 20)}
<p>{publisher}</p> <p>{publisher}</p>
</Link> </Link>
); );
return ( return (
<Card <div
key={_id} key={idx}
orientation={"vertical-2"} className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
imageUrl={url} >
hasDetails <Card
title={issueName ? titleElement : <span>No Name</span>} orientation={"vertical-2"}
> imageUrl={url}
<div className="pb-1"> hasDetails
<div className="flex flex-row gap-2"> title={issueName ? titleElement : <span>No Name</span>}
{/* Issue type */} >
{isComicBookMetadataAvailable && <div className="pb-1">
!isNil(detectIssueTypes(comicvine.description)) ? ( <div className="flex flex-row gap-2">
<div className="my-2"> {/* Issue type */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400"> {isComicBookMetadataAvailable &&
<span className="pr-1 pt-1"> !isNil(detectIssueTypes(comicvine.description)) ? (
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i> <div className="my-2">
</span> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900"> <span className="text-md text-slate-500 dark:text-slate-900">
{ {
detectIssueTypes(comicvine.description) detectIssueTypes(comicvine.description)
.displayName .displayName
} }
</span> </span>
</span> </span>
</div>
) : null}
{/* issues marked as wanted, part of this volume */}
{wanted?.markEntireVolumeWanted ? (
<div className="text-sm">sagla volume pahije</div>
) : (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{wanted.issues.length}
</span>
</span>
</div>
)}
</div>
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div> </div>
) : null} </Card>
{/* issues marked as wanted, part of this volume */}
{wanted?.markEntireVolumeWanted ? (
<div className="text-sm">sagla volume pahije</div>
) : (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{wanted.issues.length}
</span>
</span>
</div>
)}
</div> </div>
{/* comicVine metadata presence */} );
{isComicBookMetadataAvailable && ( },
<img )}
src="/src/client/assets/img/cvlogo.svg" </div>
alt={"ComicVine metadata detected."} </div>
className="w-7 h-7"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div>
</Card>
);
},
)}
</div> </div>
</> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useCallback, useEffect } from "react"; import React, { ReactElement, useCallback, useEffect, useRef } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns"; import { format } from "date-fns";
import Loader from "react-loader-spinner"; import Loader from "react-loader-spinner";
@@ -27,12 +27,21 @@ interface IProps {
export const Import = (props: IProps): ReactElement => { export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { importJobQueue, socketIOInstance } = useStore( const { importJobQueue, getSocket, setQueryClientRef } = useStore(
useShallow((state) => ({ useShallow((state) => ({
importJobQueue: state.importJobQueue, importJobQueue: state.importJobQueue,
socketIOInstance: state.socketIOInstance, getSocket: state.getSocket,
setQueryClientRef: state.setQueryClientRef,
})), })),
); );
const previousResultCountRef = useRef<number>(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Set the queryClient reference in the store so socket events can use it
useEffect(() => {
setQueryClientRef({ current: queryClient });
}, [queryClient, setQueryClientRef]);
const sessionId = localStorage.getItem("sessionId"); const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({ const { mutate: initiateImport } = useMutation({
@@ -44,24 +53,91 @@ export const Import = (props: IProps): ReactElement => {
}), }),
}); });
const { data, isError, isLoading } = useQuery({ const { data, isError, isLoading, refetch } = useQuery({
queryKey: ["allImportJobResults"], queryKey: ["allImportJobResults"],
queryFn: async () => queryFn: async () => {
await axios({ const response = await axios({
method: "GET", method: "GET",
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics", url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
}), params: {
_t: Date.now(), // Cache buster
},
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
});
// Track the result count
if (response.data?.length) {
previousResultCountRef.current = response.data.length;
}
return response;
},
refetchOnMount: true,
refetchOnWindowFocus: false,
staleTime: 0, // Always consider data stale
gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
// Poll every 5 seconds when import is running
refetchInterval: importJobQueue.status === "running" || importJobQueue.status === "paused" ? 5000 : false,
}); });
// Listen for import queue drained event to refresh the table
useEffect(() => {
const socket = getSocket("/");
const handleQueueDrained = () => {
const initialCount = previousResultCountRef.current;
let attempts = 0;
const maxAttempts = 20; // Poll for up to 20 seconds
// Clear any existing polling interval
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
// Poll every second until we see new data or hit max attempts
pollingIntervalRef.current = setInterval(async () => {
attempts++;
const result = await refetch();
const newCount = result.data?.data?.length || 0;
if (newCount > initialCount) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
} else if (attempts >= maxAttempts) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}
}, 1000);
};
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, [getSocket, queryClient, refetch]);
const toggleQueue = (queueAction: string, queueStatus: string) => { const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit( const socket = getSocket("/");
socket.emit(
"call", "call",
"socket.setQueueStatus", "socket.setQueueStatus",
{ {
queueAction, queueAction,
queueStatus, queueStatus,
}, },
(data) => console.log(data), (data: any) => console.log(data),
); );
}; };
/** /**
@@ -246,7 +322,7 @@ export const Import = (props: IProps): ReactElement => {
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{data?.data.map((jobResult, id) => { {data?.data.map((jobResult: any, id: number) => {
return ( return (
<tr key={id}> <tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">

View File

@@ -87,7 +87,7 @@ const renderCard = (props: ICardProps): ReactElement => {
<img <img
alt="Home" alt="Home"
src={props.imageUrl} src={props.imageUrl}
className="rounded-t-md object-cover" className="rounded-t-md object-cover w-full"
/> />
{props.title ? ( {props.title ? (

View File

@@ -2,17 +2,16 @@ import { create } from "zustand";
import io, { Socket } from "socket.io-client"; import io, { Socket } from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints"; import { SOCKET_BASE_URI } from "../constants/endpoints";
import { isNil } from "lodash"; import { isNil } from "lodash";
import { QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.min.css"; import "react-toastify/dist/ReactToastify.min.css";
const queryClient = new QueryClient();
// Type for global state // Type for global state
interface StoreState { interface StoreState {
socketInstances: Record<string, Socket>; socketInstances: Record<string, Socket>;
getSocket: (namespace?: string) => Socket; getSocket: (namespace?: string) => Socket;
disconnectSocket: (namespace: string) => void; disconnectSocket: (namespace: string) => void;
queryClientRef: { current: any } | null;
setQueryClientRef: (ref: any) => void;
comicvine: { comicvine: {
scrapingStatus: string; scrapingStatus: string;
@@ -32,6 +31,8 @@ interface StoreState {
export const useStore = create<StoreState>((set, get) => ({ export const useStore = create<StoreState>((set, get) => ({
socketInstances: {}, socketInstances: {},
queryClientRef: null,
setQueryClientRef: (ref: any) => set({ queryClientRef: ref }),
getSocket: (namespace = "/") => { getSocket: (namespace = "/") => {
const fullNamespace = namespace === "/" ? "" : namespace; const fullNamespace = namespace === "/" ? "" : namespace;
@@ -97,7 +98,10 @@ export const useStore = create<StoreState>((set, get) => ({
status: "drained", status: "drained",
}, },
})); }));
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] }); const queryClientRef = get().queryClientRef;
if (queryClientRef?.current) {
queryClientRef.current.invalidateQueries({ queryKey: ["allImportJobResults"] });
}
}); });
socket.on("CV_SCRAPING_STATUS", (data) => { socket.on("CV_SCRAPING_STATUS", (data) => {