Compare commits

...

6 Commits

Author SHA1 Message Date
ea66419f33 🛠 changed import to account for graphql 2026-02-24 13:39:50 -05:00
dc014a08ce [chore] moving things around in prep for graphql 2025-11-27 00:28:53 -05:00
90d6562f45 🔧 Removed useless import 2025-09-26 10:53:48 -04:00
3563fef461 💅🏼 Prettified Library Table view 2025-07-22 23:34:01 -04:00
2968987c6b 💅🏼 Prettified Volumes search results from CV 2025-07-22 18:57:24 -04:00
924ffae07e 💅🏼 Prettifying search results 2025-07-22 18:46:25 -04:00
19 changed files with 1189 additions and 812 deletions

View File

@@ -41,6 +41,8 @@
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"graphql": "^16.0.0",
"graphql-request": "^7.2.0",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"i18next": "^23.11.1", "i18next": "^23.11.1",

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(() => {
if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
const dcppSearchQuery = { const dcppSearchQuery = {
query: { query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`, pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"], extensions: ["cbz", "cbr", "cb7"],
}, },
hub_urls: map(hubs?.data, (item) => item.value), hub_urls: map(hubs.data, (item) => item.value),
priority: 5, 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,13 +342,22 @@ 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">
{isSearching ? (
<>
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" />
Searching...
</>
) : (
<>
Search DC++ Search DC++
<div className="h-5 w-5 ml-2"> <div className="h-5 w-5 ml-2">
<img <img
@@ -214,6 +365,8 @@ export const AcquisitionPanel = (
className="h-5 w-5" className="h-5 w-5"
/> />
</div> </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,8 +541,9 @@ 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={() => {
if (airDCPPSearchInstance && airDCPPConfig) {
download( download(
airDCPPSearchInstance.id, airDCPPSearchInstance.id,
id, id,
@@ -387,14 +551,11 @@ export const AcquisitionPanel = (
name, name,
size, size,
type, type,
{ airDCPPConfig,
protocol: `ws`, );
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

@@ -1,7 +1,7 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import Select from "react-select"; import Select from "react-select";
export const Menu = (props): ReactElement => { export const Menu = (props: any): ReactElement => {
const { const {
filteredActionOptions, filteredActionOptions,
customStyles, customStyles,
@@ -13,11 +13,11 @@ export const Menu = (props): ReactElement => {
<Select <Select
components={{ Placeholder }} components={{ Placeholder }}
placeholder={ placeholder={
<span className="inline-flex flex-row items-center gap-2 pt-1"> <span className="inline-flex flex-row items-center gap-1.5 pt-1">
<div className="w-6 h-6"> <div className="w-4 h-4">
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i> <i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
</div> </div>
<div>Select An Action</div> <div className="text-sm">Select An Action</div>
</span> </span>
} }
styles={customStyles} styles={customStyles}

View File

@@ -18,7 +18,6 @@ import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil, filter } from "lodash"; import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select"; import { components } from "react-select";
import { RootState } from "threetwo-ui-typings";
import "react-sliding-pane/dist/react-sliding-pane.css"; import "react-sliding-pane/dist/react-sliding-pane.css";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
@@ -37,20 +36,30 @@ import { refineQuery } from "filename-parser";
interface ComicDetailProps { interface ComicDetailProps {
data: { data: {
_id: string; _id: string;
rawFileDetails: {}; rawFileDetails?: any;
inferredMetadata: { inferredMetadata?: {
issue: {}; issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
}; };
sourcedMetadata: { sourcedMetadata: {
comicvine: {}; comicvine?: any;
locg: {}; locg?: any;
comicInfo: {}; comicInfo?: any;
};
acquisition?: {
directconnect?: {
downloads?: any[];
};
torrent?: any[];
}; };
acquisition: {};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
userSettings: {}; userSettings?: any;
} }
/** /**
* Component for displaying the metadata for a comic in greater detail. * Component for displaying the metadata for a comic in greater detail.
@@ -102,7 +111,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const StyledSlidingPanel = styled(SlidingPane)` const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc; background: #ccc;
`; `;
const afterOpenModal = useCallback((things) => { const afterOpenModal = useCallback((things: any) => {
// references are now sync'd and can be accessed. // references are now sync'd and can be accessed.
// subtitle.style.color = "#f00"; // subtitle.style.color = "#f00";
console.log("kolaveri", things); console.log("kolaveri", things);
@@ -113,9 +122,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, []); }, []);
// sliding panel init // sliding panel init
const contentForSlidingPanel = { const contentForSlidingPanel: Record<string, { content: (props?: any) => JSX.Element }> = {
CVMatches: { CVMatches: {
content: (props) => ( content: (props?: any) => (
<> <>
<div> <div>
<ComicVineSearchForm data={rawFileDetails} /> <ComicVineSearchForm data={rawFileDetails} />
@@ -123,7 +132,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<div className="border-slate-500 border rounded-lg p-2 mt-3"> <div className="border-slate-500 border rounded-lg p-2 mt-3">
<p className="">Searching for:</p> <p className="">Searching for:</p>
{inferredMetadata.issue ? ( {inferredMetadata?.issue ? (
<> <>
<span className="">{inferredMetadata.issue.name} </span> <span className="">{inferredMetadata.issue.name} </span>
<span className=""> # {inferredMetadata.issue.number} </span> <span className=""> # {inferredMetadata.issue.number} </span>
@@ -148,9 +157,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// Actions // Actions
const fetchComicVineMatches = async ( const fetchComicVineMatches = async (
searchPayload, searchPayload: any,
issueSearchQuery, issueSearchQuery: any,
seriesSearchQuery, seriesSearchQuery: any,
) => { ) => {
try { try {
const response = await axios({ const response = await axios({
@@ -170,7 +179,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, },
rawFileDetails: searchPayload, rawFileDetails: searchPayload,
}, },
transformResponse: (r) => { transformResponse: (r: string) => {
const matches = JSON.parse(r); const matches = JSON.parse(r);
return matches; return matches;
// return sortBy(matches, (match) => -match.score); // return sortBy(matches, (match) => -match.score);
@@ -180,9 +189,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
if (!isNil(response.data.results) && response.data.results.length === 1) { if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results; matches = response.data.results;
} else { } else {
matches = response.data.map((match) => match); matches = response.data.map((match: any) => match);
} }
const scoredMatches = matches.sort((a, b) => b.score - a.score); const scoredMatches = matches.sort((a: any, b: any) => b.score - a.score);
setComicVineMatches(scoredMatches); setComicVineMatches(scoredMatches);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -191,13 +200,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// Action event handlers // Action event handlers
const openDrawerWithCVMatches = () => { const openDrawerWithCVMatches = () => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; let seriesSearchQuery: any = {};
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; let issueSearchQuery: any = {};
if (!isUndefined(rawFileDetails)) { if (!isUndefined(rawFileDetails)) {
issueSearchQuery = refineQuery(rawFileDetails.name); issueSearchQuery = refineQuery((rawFileDetails as any).name);
} else if (!isEmpty(comicvine)) { } else if (!isEmpty(comicvine)) {
issueSearchQuery = refineQuery(comicvine.name); issueSearchQuery = refineQuery((comicvine as any).name);
} }
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery); fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
setSlidingPanelContentId("CVMatches"); setSlidingPanelContentId("CVMatches");
@@ -211,30 +220,30 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// Actions menu options and handler // Actions menu options and handler
const CVMatchLabel = ( const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-2"> <span className="inline-flex flex-row items-center gap-1.5">
<div className="w-6 h-6"> <div className="w-4 h-4">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i> <i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
</div> </div>
<div>Match on ComicVine</div> <div className="text-sm">Match on ComicVine</div>
</span> </span>
); );
const editLabel = ( const editLabel = (
<span className="inline-flex flex-row items-center gap-2"> <span className="inline-flex flex-row items-center gap-1.5">
<div className="w-6 h-6"> <div className="w-4 h-4">
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i> <i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
</div> </div>
<div>Edit Metadata</div> <div className="text-sm">Edit Metadata</div>
</span> </span>
); );
const deleteLabel = ( const deleteLabel = (
<span className="inline-flex flex-row items-center gap-2"> <span className="inline-flex flex-row items-center gap-1.5">
<div className="w-6 h-6"> <div className="w-4 h-4">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i> <i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
</div> </div>
<div>Delete Comic</div> <div className="text-sm">Delete Comic</div>
</span> </span>
); );
const Placeholder = (props) => { const Placeholder = (props: any) => {
return <components.Placeholder {...props} />; return <components.Placeholder {...props} />;
}; };
const actionOptions = [ const actionOptions = [
@@ -249,7 +258,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
} }
return item; return item;
}); });
const handleActionSelection = (action) => { const handleActionSelection = (action: any) => {
switch (action.value) { switch (action.value) {
case "match-on-comic-vine": case "match-on-comic-vine":
openDrawerWithCVMatches(); openDrawerWithCVMatches();
@@ -263,23 +272,23 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
} }
}; };
const customStyles = { const customStyles = {
menu: (base) => ({ menu: (base: any) => ({
...base, ...base,
backgroundColor: "rgb(156, 163, 175)", backgroundColor: "rgb(156, 163, 175)",
}), }),
placeholder: (base) => ({ placeholder: (base: any) => ({
...base, ...base,
color: "black", color: "black",
}), }),
option: (base, { data, isDisabled, isFocused, isSelected }) => ({ option: (base: any, { data, isDisabled, isFocused, isSelected }: any) => ({
...base, ...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)", backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}), }),
singleValue: (base) => ({ singleValue: (base: any) => ({
...base, ...base,
paddingTop: "0.4rem", paddingTop: "0.4rem",
}), }),
control: (base) => ({ control: (base: any) => ({
...base, ...base,
backgroundColor: "rgb(156, 163, 175)", backgroundColor: "rgb(156, 163, 175)",
color: "black", color: "black",
@@ -289,7 +298,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// check for the availability of CV metadata // check for the availability of CV metadata
const isComicBookMetadataAvailable = const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation); !isUndefined(comicvine) && !isUndefined((comicvine as any)?.volumeInformation);
// check for the availability of rawFileDetails // check for the availability of rawFileDetails
const areRawFileDetailsAvailable = const areRawFileDetailsAvailable =
@@ -354,7 +363,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
query={airDCPPQuery} query={airDCPPQuery}
comicObjectId={_id} comicObjectId={_id}
comicObject={data.data} comicObject={data.data}
userSettings={userSettings} settings={userSettings}
key={4} key={4}
/> />
), ),
@@ -376,8 +385,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
name: "Downloads", name: "Downloads",
icon: ( icon: (
<> <>
{acquisition?.directconnect?.downloads?.length + {(acquisition?.directconnect?.downloads?.length || 0) +
acquisition?.torrent.length} (acquisition?.torrent?.length || 0)}
</> </>
), ),
content: content:
@@ -414,11 +423,12 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation={"cover-only"}
hasDetails={false} hasDetails={false}
cardContainerStyle={{ maxWidth: "290px", width: "100%" }}
/> />
{/* raw file details */} {/* raw file details */}
{!isUndefined(rawFileDetails) && {!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && ( !isEmpty((rawFileDetails as any)?.cover) && (
<div className="grid"> <div className="grid">
<RawFileDetails <RawFileDetails
data={{ data={{
@@ -468,7 +478,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<TabControls <TabControls
filteredTabs={filteredTabs} filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length} downloadCount={acquisition?.directconnect?.downloads?.length || 0}
/> />
<StyledSlidingPanel <StyledSlidingPanel

View File

@@ -30,95 +30,126 @@ interface RawFileDetailsProps {
children?: any; children?: any;
} }
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => { export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } = const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data; props.data || {};
if (!rawFileDetails) return null;
return ( return (
<> <>
<div className="max-w-2xl ml-5"> <div className="max-w-2xl ml-5">
<div className="px-4 sm:px-6"> {/* Title */}
<div className="px-4 sm:px-6 mb-6">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
<span className="text-xl">{rawFileDetails.name}</span> <span className="text-xl font-semibold">{rawFileDetails?.name}</span>
</p> </p>
</div> </div>
<div className="px-4 py-5 sm:px-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2"> {/* File Binary Details Section */}
<div className="sm:col-span-1"> <div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <div className="mb-4">
Raw File Details <h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
<i className="icon-[solar--document-bold-duotone] w-5 h-5"></i>
File Binary Details
</h3>
</div>
<div className="pl-6">
<dl className="space-y-4">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
File Path
</dt> </dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400"> <dd className="text-sm text-gray-900 dark:text-gray-300 font-mono break-all">
{rawFileDetails.containedIn + {rawFileDetails?.containedIn}/{rawFileDetails?.name}{rawFileDetails?.extension}
"/" +
rawFileDetails.name +
rawFileDetails.extension}
</dd> </dd>
</div> </div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
Inferred Issue Metadata <div>
</dt> <dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
Series Name: {inferredMetadata.issue.name}
{!isEmpty(inferredMetadata.issue.number) ? (
<span className="tag is-primary is-light">
{inferredMetadata.issue.number}
</span>
) : null}
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
MIME Type MIME Type
</dt> </dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900"> <dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span> {rawFileDetails?.mimeType}
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
</dd> </dd>
</div> </div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
File Size File Size
</dt> </dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900"> <dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span> {rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : 'N/A'}
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
</dd> </dd>
</div> </div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Actions
</dt>
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
</div> </div>
</dl> </dl>
</div> </div>
</div> </div>
{/* Import Details Section */}
<div className="mb-8 px-4">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
<i className="icon-[solar--import-bold-duotone] w-5 h-5"></i>
Import Details
</h3>
</div>
<div className="pl-6">
<dl className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Imported On
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
{created_at ? format(parseISO(created_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Last Updated
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
{updated_at ? format(parseISO(updated_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
</dd>
</div>
</div>
{inferredMetadata?.issue && (
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Inferred Metadata
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">{inferredMetadata.issue.name}</span>
{!isEmpty(inferredMetadata.issue.number) && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
#{inferredMetadata.issue.number}
</span>
)}
</dd>
</div>
)}
</dl>
</div>
</div>
{/* Actions Section */}
{props.children && (
<div className="px-4">
<div className="mb-3">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide">
Actions
</h4>
</div>
<div>{props.children}</div>
</div>
)}
</div>
</> </>
); );
}; };

View File

@@ -14,12 +14,16 @@ import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils"; import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
export const ArchiveOperations = (props): ReactElement => { interface ArchiveOperationsProps {
data: any;
}
export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement => {
const { data } = props; const { data } = props;
const { socketIOInstance } = useStore( const { getSocket } = useStore(
useShallow((state) => ({ useShallow((state) => ({
socketIOInstance: state.socketIOInstance, getSocket: state.getSocket,
})), })),
); );
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -27,21 +31,32 @@ export const ArchiveOperations = (props): ReactElement => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image // current image
const [currentImage, setCurrentImage] = useState([]); const [currentImage, setCurrentImage] = useState<string>("");
const [uncompressedArchive, setUncompressedArchive] = useState([]); const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState({}); const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] = const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false); useState(false);
const constructImagePaths = (data): Array<string> => { const constructImagePaths = (data: string[]): Array<string> => {
return data?.map((path: string) => return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)), escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
); );
}; };
// Listen to the uncompression complete event and orchestrate the final payload // Listen to the uncompression complete event and orchestrate the final payload
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => { useEffect(() => {
const socket = getSocket("/");
const handleUncompressionComplete = (data: any) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive)); setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
}); };
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
// Cleanup listener on unmount
return () => {
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
};
}, [getSocket]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -58,7 +73,7 @@ export const ArchiveOperations = (props): ReactElement => {
}, },
transformResponse: async (responseData) => { transformResponse: async (responseData) => {
const parsedData = JSON.parse(responseData); const parsedData = JSON.parse(responseData);
const paths = parsedData.map((pathObject) => { const paths = parsedData.map((pathObject: any) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`; return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
}); });
const uncompressedArchive = constructImagePaths(paths); const uncompressedArchive = constructImagePaths(paths);
@@ -131,7 +146,7 @@ export const ArchiveOperations = (props): ReactElement => {
} }
// sliding panel init // sliding panel init
const contentForSlidingPanel = { const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
imageAnalysis: { imageAnalysis: {
content: () => { content: () => {
return ( return (
@@ -143,7 +158,7 @@ export const ArchiveOperations = (props): ReactElement => {
</pre> </pre>
) : null} ) : null}
<pre className="font-hasklig mt-3 text-sm"> <pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)} {JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
</pre> </pre>
</div> </div>
); );
@@ -152,7 +167,7 @@ export const ArchiveOperations = (props): ReactElement => {
}; };
// sliding panel handlers // sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => { const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
setSlidingPanelContentId("imageAnalysis"); setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath); analyzeImage(imageFilePath);
setCurrentImage(imageFilePath); setCurrentImage(imageFilePath);

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useState, useEffect } from "react";
import { map } from "lodash"; import { map } from "lodash";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import Header from "../shared/Header"; import Header from "../shared/Header";
@@ -34,24 +34,35 @@ export const PullList = (): ReactElement => {
format(date, "yyyy/M/dd"), format(date, "yyyy/M/dd"),
); );
// Responsive slides per view
const [slidesPerView, setSlidesPerView] = useState(1);
// keen slider // keen slider
const [sliderRef, instanceRef] = useKeenSlider( const [sliderRef, instanceRef] = useKeenSlider({
{
loop: true, loop: true,
mode: "free-snap",
slides: { slides: {
origin: "auto", perView: slidesPerView,
number: 15,
perView: 5,
spacing: 15, spacing: 15,
}, },
slideChanged() { slideChanged() {
console.log("slide changed"); console.log("slide changed");
}, },
});
// Update slider when slidesPerView changes
useEffect(() => {
if (instanceRef.current) {
instanceRef.current.update({
slides: {
perView: slidesPerView,
spacing: 15,
}, },
[ });
// add plugins here }
], }, [slidesPerView, instanceRef]);
);
const { const {
data: pullList, data: pullList,
@@ -80,6 +91,7 @@ export const PullList = (): ReactElement => {
return ( return (
<> <>
<div className="content"> <div className="content">
<div className="mx-auto">
<Header <Header
headerContent="Discover" headerContent="Discover"
subHeaderContent={ subHeaderContent={
@@ -124,9 +136,10 @@ export const PullList = (): ReactElement => {
</div> </div>
</div> </div>
</div> </div>
</div>
{isSuccess && !isLoading && ( {isSuccess && !isLoading && (
<div ref={sliderRef} className="keen-slider flex flex-row"> <div ref={sliderRef} className="keen-slider">
{map(pullList?.data.result, (issue, idx) => { {map(pullList?.data.result, (issue, idx) => {
return ( return (
<div key={idx} className="keen-slider__slide"> <div key={idx} className="keen-slider__slide">

View File

@@ -21,12 +21,13 @@ export const RecentlyImported = (
console.log(comics); console.log(comics);
return ( return (
<div> <div>
<div className="mx-auto" style={{ maxWidth: '1400px' }}>
<Header <Header
headerContent="Recently Imported" headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc." subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2" iconClassNames="fa-solid fa-binoculars mr-2"
/> />
<div className="grid grid-cols-5 gap-6 mt-3"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 gap-6 mt-3">
{comics?.comics.map( {comics?.comics.map(
( (
{ {
@@ -127,5 +128,6 @@ export const RecentlyImported = (
)} )}
</div> </div>
</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,23 +17,36 @@ 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">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map( {map(
comics, comics,
({ (
{
_id, _id,
rawFileDetails, rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg }, sourcedMetadata: { comicvine, comicInfo, locg },
wanted, wanted,
}) => { },
idx,
) => {
const isComicBookMetadataAvailable = !isUndefined(comicvine); const isComicBookMetadataAvailable = !isUndefined(comicvine);
const consolidatedComicMetadata = { const consolidatedComicMetadata = {
rawFileDetails, rawFileDetails,
@@ -53,8 +67,11 @@ export const WantedComicsList = ({
</Link> </Link>
); );
return ( return (
<div
key={idx}
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]"
>
<Card <Card
key={_id}
orientation={"vertical-2"} orientation={"vertical-2"}
imageUrl={url} imageUrl={url}
hasDetails hasDetails
@@ -102,7 +119,7 @@ export const WantedComicsList = ({
<img <img
src="/src/client/assets/img/cvlogo.svg" src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."} alt={"ComicVine metadata detected."}
className="w-7 h-7" className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
/> />
)} )}
{!isEmpty(locg) && ( {!isEmpty(locg) && (
@@ -113,10 +130,13 @@ export const WantedComicsList = ({
)} )}
</div> </div>
</Card> </Card>
</div>
); );
}, },
)} )}
</div> </div>
</> </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,13 +27,22 @@ 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({
mutationFn: async () => mutationFn: async () =>
@@ -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

@@ -152,10 +152,10 @@ export const Library = (): ReactElement => {
accessorKey: "_source.createdAt", accessorKey: "_source.createdAt",
cell: (info) => { cell: (info) => {
return !isNil(info.getValue()) ? ( return !isNil(info.getValue()) ? (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900"> <span className="inline-flex items-center bg-slate-300 dark:bg-slate-500 text-xs font-medium text-slate-700 dark:text-slate-200 px-3 py-1 rounded-md shadow-sm whitespace-nowrap ml-3 my-3">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p> <i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" />
{format(parseISO(info.getValue()), "h aaaa")} {format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")}
</div> </span>
) : null; ) : null;
}, },
}, },
@@ -164,23 +164,25 @@ export const Library = (): ReactElement => {
accessorKey: "_source.acquisition", accessorKey: "_source.acquisition",
cell: (info) => ( cell: (info) => (
<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"> {/* DC++ Downloads */}
<span className="pr-1 pt-1"> {info.getValue().directconnect?.downloads?.length > 0 ? (
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
</span> <i className="icon-[solar--folder-path-connect-bold-duotone] w-4 h-4 mr-1 opacity-70" />
<span className="text-md text-slate-900 dark:text-slate-900"> <span>
DC++: {info.getValue().directconnect.downloads.length} DC++: {info.getValue().directconnect.downloads.length}
</span> </span>
</span> </span>
) : null}
<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"> {/* Torrent Downloads */}
<span className="pr-1 pt-1"> {info.getValue().torrent.length > 0 ? (
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i> <span className="inline-flex items-center whitespace-nowrap bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
</span> <i className="icon-[solar--magnet-bold-duotone] w-4 h-4 mr-1 opacity-70" />
<span className="text-md text-slate-900 dark:text-slate-900"> <span className="whitespace-nowrap">
Torrent: {info.getValue().torrent.length} Torrent: {info.getValue().torrent.length}
</span> </span>
</span> </span>
) : null}
</div> </div>
), ),
}, },

View File

@@ -266,55 +266,80 @@ export const Search = ({}: ISearchProps): ReactElement => {
</div> </div>
)} )}
{!isEmpty(comicVineSearchResults?.data?.results) ? ( {!isEmpty(comicVineSearchResults?.data?.results) ? (
<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 w-full sm:w-[90vw] md:w-[80vw] lg:w-[70vw] max-w-6xl px-4 py-6">
{comicVineSearchResults.data.results.map((result) => { {comicVineSearchResults.data.results.map((result) => {
return result.resource_type === "issue" ? ( return result.resource_type === "issue" ? (
<div <div
key={result.id} key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg" className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
> >
<div className="flex flex-row"> {/* IMAGE */}
<div className="mr-5 min-w-[80px] max-w-[13%]"> <div className="flex-shrink-0">
<Card <Card
key={result.id} orientation="cover-only"
orientation={"cover-only"}
imageUrl={result.image.small_url} imageUrl={result.image.small_url}
hasDetails={false} hasDetails={false}
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
/> />
</div> </div>
<div className="w-3/4">
<div className="text-xl"> {/* RIGHT-SIDE CONTENT */}
{!isEmpty(result.volume.name) ? ( <div className="flex-1 min-w-0">
result.volume.name {/* TITLE */}
) : ( <div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
<span className="is-size-3">No Name</span> {result.volume?.name || <span>No Name</span>}
)}
</div> </div>
{/* SUBMETA */}
<div className="flex flex-wrap gap-2 mt-2">
{/* Cover Date Token */}
{result.cover_date && ( {result.cover_date && (
<p> <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="tag is-light">Cover date</span> <span className="pr-1 pt-1">
{dayjs(result.cover_date).format("MMM D, YYYY")} <i className="icon-[solar--calendar-bold-duotone] w-4 h-4"></i>
</p> </span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{dayjs(result.cover_date).format("MMM YYYY")}
</span>
</span>
)} )}
<p className="tag is-warning">{result.id}</p> {/* ID Token */}
<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--hashtag-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{result.id}
</span>
</span>
</div>
<a href={result.api_detail_url}> {/* LINK */}
<a
href={result.api_detail_url}
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
>
{result.api_detail_url} {result.api_detail_url}
</a> </a>
<p className="text-sm">
{/* DESCRIPTION */}
{result.description && (
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
{ellipsize( {ellipsize(
convert(result.description, { convert(result.description ?? "", {
baseElements: { baseElements: { selectors: ["p", "div"] },
selectors: ["p", "div"],
},
}), }),
320, 300,
)} )}
</p> </p>
<div className="mt-2"> )}
{/* CTA BUTTON */}
{result.volume.name && (
<div className="absolute bottom-4 right-4">
<PopoverButton <PopoverButton
content={`This will add ${result.volume.name} to your wanted list.`} content={`This will add ${result?.volume?.name} to your wanted list.`}
clickHandler={() => clickHandler={() =>
addToWantedList({ addToWantedList({
source: "comicvine", source: "comicvine",
@@ -325,114 +350,109 @@ export const Search = ({}: ISearchProps): ReactElement => {
} }
/> />
</div> </div>
</div> )}
</div> </div>
</div> </div>
) : ( ) : (
result.resource_type === "volume" && ( result.resource_type === "volume" && (
<div <div
key={result.id} key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg" className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
> >
<div className="flex flex-row"> {/* LEFT COLUMN: COVER */}
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card <Card
key={result.id} orientation="cover-only"
orientation={"cover-only"}
imageUrl={result.image.small_url} imageUrl={result.image.small_url}
hasDetails={false} hasDetails={false}
cardContainerStyle={{
width: "120px",
maxWidth: "150px",
}}
/> />
</div>
<div className="w-3/4"> {/* RIGHT COLUMN */}
<div className="text-xl"> <div className="flex-1 min-w-0">
{!isEmpty(result.name) ? ( {/* TITLE */}
result.name <div className="text-lg font-bold text-gray-900 dark:text-white">
) : ( {result.name || <span>No Name</span>}
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>} {result.start_year && <> ({result.start_year})</>}
</div> </div>
<div className="flex flex-row gap-2"> {/* TOKENS */}
{/* issue count */} <div className="flex flex-wrap gap-2 mt-2">
{/* ISSUE COUNT */}
{result.count_of_issues && ( {result.count_of_issues && (
<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="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium 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--documents-minimalistic-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--documents-minimalistic-bold-duotone] w-4 h-4" />
</span> </span>
<span>
<span className="text-md text-slate-500 dark:text-slate-900">
{t("issueWithCount", { {t("issueWithCount", {
count: result.count_of_issues, count: result.count_of_issues,
})} })}
</span> </span>
</span> </span>
</div>
)} )}
{/* type: TPB, one-shot, graphic novel etc. */}
{!isNil(result.description) &&
!isUndefined(result.description) && (
<>
{!isEmpty(
detectIssueTypes(result.description),
) && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 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"> {/* FORMAT DETECTED */}
{result.description &&
!isEmpty(detectIssueTypes(result.description)) && (
<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-4 h-4" />
</span>
<span>
{ {
detectIssueTypes(result.description) detectIssueTypes(result.description)
.displayName .displayName
} }
</span> </span>
</span> </span>
</div>
)}
</>
)} )}
{/* ID */}
<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--hashtag-bold-duotone] w-4 h-4" />
</span>
<span>{result.id}</span>
</span>
</div> </div>
<span className="tag is-warning">{result.id}</span> {/* LINK */}
<p> <a
<a href={result.api_detail_url}> href={result.api_detail_url}
className="text-sm text-blue-500 underline mt-2 break-all"
>
{result.api_detail_url} {result.api_detail_url}
</a> </a>
</p>
{/* description */} {/* DESCRIPTION */}
<p className="text-sm"> {result.description && (
<p className="text-sm mt-2 text-slate-700 dark:text-slate-200 break-words line-clamp-3">
{ellipsize( {ellipsize(
convert(result.description, { convert(result.description, {
baseElements: { baseElements: { selectors: ["p", "div"] },
selectors: ["p", "div"],
},
}), }),
320, 320,
)} )}
</p> </p>
<div className="mt-2"> )}
{result.name ? (
<div className="mt-4 justify-self-end">
<PopoverButton <PopoverButton
content={`Adding this volume will add ${t( content={`This will add ${result.count_of_issues} issues your wanted list.`}
"issueWithCount",
{
count: result.count_of_issues,
},
)} to your wanted list.`}
clickHandler={() => clickHandler={() =>
addToWantedList({ addToWantedList({
source: "comicvine", source: "comicvine",
comicObject: result, comicObject: result,
markEntireVolumeWanted: true, markEntireVolumeWanted: false,
resourceType: "volume", resourceType: "issue",
}) })
} }
/> />
</div> </div>
</div> ) : null}
</div> </div>
</div> </div>
) )

View File

@@ -1,37 +1,87 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react"; import React from "react";
import SearchBar from "../Library/SearchBar"; import { useQuery } from "@tanstack/react-query";
import { gql, GraphQLClient } from "graphql-request";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const WantedComics = (props): ReactElement => { /**
const { * GraphQL client for interfacing with Moleculer Apollo server.
data: wantedComics, */
isSuccess, const client = new GraphQLClient("http://localhost:3000/graphql");
isFetched,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
pagination: { /**
size: 25, * GraphQL query to fetch wanted comics.
from: 0, */
}, const WANTED_COMICS_QUERY = gql`
type: "wanted", query {
trigger: "wantedComicsPage", wantedComics(limit: 25, offset: 0) {
}, total
}), comics
}
}
`;
/**
* Shape of an individual comic returned by the backend.
*/
type Comic = {
_id: string;
sourcedMetadata?: {
comicvine?: {
name?: string;
start_year?: string;
publisher?: {
name?: string;
};
};
};
acquisition?: {
directconnect?: {
downloads?: Array<{
name: string;
}>;
};
};
};
/**
* Shape of the GraphQL response returned for wanted comics.
*/
type WantedComicsResponse = {
wantedComics: {
total: number;
comics: Comic[];
};
};
/**
* React component rendering the "Wanted Comics" table using T2Table.
* Fetches data from GraphQL backend via graphql-request + TanStack Query.
*
* @component
* @returns {JSX.Element} React component
*/
const WantedComics = (): JSX.Element => {
const { data, isLoading, isError, isSuccess, error } = useQuery<
WantedComicsResponse["wantedComics"]
>({
queryKey: ["wantedComics"], queryKey: ["wantedComics"],
enabled: true, queryFn: async () => {
const res = await client.request<WantedComicsResponse>(
WANTED_COMICS_QUERY,
);
if (!res?.wantedComics?.comics) {
throw new Error("No comics returned");
}
return res.wantedComics;
},
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}); });
const columnData = [ const columnData = [
{ {
header: "Comic Information", header: "Comic Information",
@@ -40,11 +90,11 @@ export const WantedComics = (props): ReactElement => {
header: "Details", header: "Details",
id: "comicDetails", id: "comicDetails",
minWidth: 350, minWidth: 350,
accessorFn: (data) => data, accessorFn: (data: Comic) => data,
cell: (value) => { cell: (value: any) => {
console.log("ASDASd", value); const row = value.getValue();
const row = value.getValue()._source; console.log("Comic row data:", row);
return row && <MetadataPanel data={row} />; return row ? <MetadataPanel data={row} /> : null;
}, },
}, },
], ],
@@ -55,110 +105,36 @@ export const WantedComics = (props): ReactElement => {
{ {
header: "Files", header: "Files",
align: "right", align: "right",
accessorKey: "_source.acquisition", accessorFn: (row: Comic) =>
cell: (props) => { row?.acquisition?.directconnect?.downloads || [],
const { cell: (props: any) => {
directconnect: { downloads }, const downloads = props.getValue();
} = props.getValue(); return downloads?.length > 0 ? (
return (
<div
style={{
display: "flex",
// flexDirection: "column",
justifyContent: "center",
}}
>
{downloads.length > 0 ? (
<span className="tag is-warning">{downloads.length}</span> <span className="tag is-warning">{downloads.length}</span>
) : null} ) : null;
</div>
);
}, },
}, },
{ {
header: "Download Details", header: "Download Details",
id: "downloadDetails", id: "downloadDetails",
accessorKey: "_source.acquisition", accessorFn: (row: Comic) =>
cell: (data) => ( row?.acquisition?.directconnect?.downloads || [],
cell: (data: any) => (
<ol> <ol>
{data.getValue().directconnect.downloads.map((download, idx) => { {data.getValue()?.map((download: any, idx: number) => (
return (
<li className="is-size-7" key={idx}> <li className="is-size-7" key={idx}>
{download.name} {download.name}
</li> </li>
); ))}
})}
</ol> </ol>
), ),
}, },
{
header: "Type",
id: "dcc",
},
], ],
}, },
]; ];
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from: pageSize * pageIndex + 1,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
// let from = 0;
// if (pageIndex === 2) {
// from = (pageIndex - 1) * pageSize + 2 - 17;
// } else {
// from = (pageIndex - 1) * pageSize + 2 - 16;
// }
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
return ( return (
<div className=""> <section>
<section className="">
<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-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between"> <div className="sm:flex sm:items-center sm:justify-between">
@@ -166,7 +142,6 @@ export const WantedComics = (props): 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">
Wanted Comics Wanted Comics
</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 through comics you marked as "wanted." Browse through comics you marked as "wanted."
</p> </p>
@@ -174,29 +149,29 @@ export const WantedComics = (props): ReactElement => {
</div> </div>
</div> </div>
</header> </header>
{isSuccess && wantedComics?.data.hits?.hits ? (
<div> {isLoading && (
<div className="library"> <div className="animate-pulse p-4 space-y-4">
<T2Table {Array.from({ length: 5 }).map((_, idx) => (
sourceData={wantedComics?.data.hits.hits} <div
totalPages={wantedComics?.data.hits.hits.length} key={idx}
columns={columnData} className="h-24 bg-slate-300 dark:bg-slate-600 rounded-md"
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
}}
// rowClickHandler={navigateToComicDetail}
/> />
{/* pagination controls */} ))}
</div> </div>
</div> )}
) : null} {isError && <div>Error fetching wanted comics. {error?.message}</div>}
{isLoading ? <div>Loading...</div> : null} {isSuccess && data?.comics?.length > 0 ? (
{isError ? ( <T2Table
<div>An error occurred while retrieving the pull list.</div> sourceData={data.comics}
totalPages={data.comics.length}
columns={columnData}
paginationHandlers={{}}
/>
) : isSuccess ? (
<div>No comics found.</div>
) : null} ) : null}
</section> </section>
</div>
); );
}; };

View File

@@ -11,8 +11,8 @@ interface ICardProps {
borderColorClass?: string; borderColorClass?: string;
backgroundColor?: string; backgroundColor?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: PropTypes.object; cardContainerStyle?: any;
imageStyle?: PropTypes.object; imageStyle?: any;
} }
const renderCard = (props: ICardProps): ReactElement => { const renderCard = (props: ICardProps): ReactElement => {
@@ -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 ? (
@@ -140,14 +140,31 @@ const renderCard = (props: ICardProps): ReactElement => {
); );
case "cover-only": case "cover-only":
const containerStyle = {
width: props.cardContainerStyle?.width || "100%",
height: props.cardContainerStyle?.height || "auto",
maxWidth: props.cardContainerStyle?.maxWidth || "none",
...props.cardContainerStyle,
};
const imageStyle = {
width: "100%",
height: "100%",
objectFit: "cover",
...props.imageStyle,
};
return ( return (
<> <div
{/* thumbnail */} className={`rounded-2xl overflow-hidden shadow-md bg-white dark:bg-slate-800 ${
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit"> props.cardContainerStyle?.height ? "" : "aspect-[2/3]"
<img src={props.imageUrl} /> }`}
style={containerStyle}
>
<img src={props.imageUrl} alt="Comic cover" style={imageStyle} />
</div> </div>
</>
); );
case "card-with-info-panel": case "card-with-info-panel":
return ( return (
<> <>

View File

@@ -1,5 +1,4 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { Card } from "../shared/Carda"; import { Card } from "../shared/Carda";
@@ -7,26 +6,48 @@ import { convert } from "html-to-text";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash"; import { find, isUndefined } from "lodash";
interface IMetadatPanelProps { /**
value: any; * Props for the MetadataPanel component.
children: any; */
imageStyle: any; interface MetadataPanelProps {
titleStyle: any; /**
tagsStyle: any; * Comic metadata object passed into the panel.
containerStyle: any; */
data: any;
/**
* Optional custom styling for the cover image.
*/
imageStyle?: React.CSSProperties;
/**
* Optional custom styling for the title section.
*/
titleStyle?: React.CSSProperties;
} }
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
/**
* MetadataPanel component
*
* Displays structured comic metadata based on the best available source
* (raw file data, ComicVine, or League of Comic Geeks).
*
* @component
* @param {MetadataPanelProps} props
* @returns {ReactElement}
*/
export const MetadataPanel = (props: MetadataPanelProps): ReactElement => {
const { const {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,
sourcedMetadata: { comicvine, locg }, sourcedMetadata: { comicvine, locg },
} = props.data; } = props.data;
const { issueName, url, objectReference } = determineCoverFile({ const { issueName, url, objectReference } = determineCoverFile({
comicvine, comicvine,
locg, locg,
rawFileDetails, rawFileDetails,
}); });
const metadataContentPanel = [ const metadataContentPanel = [
{ {
name: "rawFileDetails", name: "rawFileDetails",
@@ -43,48 +64,29 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span> </span>
</dd> </dd>
{/* Issue number */}
{inferredMetadata.issue.number && ( {inferredMetadata.issue.number && (
<dd className="my-2"> <dd className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
<span className="pr-1 pt-1"> <i className="icon-[solar--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70"></i>
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i> <span>{inferredMetadata.issue.number}</span>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
{inferredMetadata.issue.number}
</span>
</span> </span>
</dd> </dd>
)} )}
<dd className="flex flex-row gap-2 w-max">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900"> <dd className="flex flex-row gap-2 w-max">
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
<i className="icon-[solar--file-text-bold-duotone] w-4 h-4 mr-1 opacity-70" />
{rawFileDetails.mimeType} {rawFileDetails.mimeType}
</span> </span>
</span>
{/* size */} <span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <i className="icon-[solar--database-bold-duotone] w-4 h-4 mr-1 opacity-70" />
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)} {prettyBytes(rawFileDetails.fileSize)}
</span> </span>
</span>
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && ( {rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1"> <i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5 pr-1 pt-1" />
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span> </span>
)} )}
</dd> </dd>
@@ -94,49 +96,56 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "comicvine", name: "comicvine",
content: () => content: () => {
!isUndefined(comicvine) && return (
!isUndefined(comicvine.volumeInformation) && ( !isUndefined(comicvine?.volumeInformation) && (
<dl> <dl className="space-y-1 text-sm text-slate-700 dark:text-slate-200">
<dt> {/* Title */}
<h6 <dt className="text-base font-semibold text-slate-900 dark:text-white">
className="name has-text-weight-medium mb-1" {ellipsize(issueName, 28)}
style={props.titleStyle}
>
{ellipsize(issueName, 18)}
</h6>
</dt> </dt>
{/* Volume Name */}
<dd> <dd>
<span className="is-size-7"> <span className="text-sm text-slate-600 dark:text-slate-300">
Is a part of{" "} Part of{" "}
<span className="has-text-weight-semibold"> <span className="font-medium text-slate-800 dark:text-white">
{comicvine.volumeInformation.name} {comicvine.volumeInformation.name}
</span> </span>
</span> </span>
</dd> </dd>
<dd className="is-size-7">
<span> {/* Description */}
<dd className="text-slate-600 dark:text-slate-300">
{ellipsize( {ellipsize(
convert(comicvine.description, { convert(comicvine.description || "", {
baseElements: { baseElements: { selectors: ["p"] },
selectors: ["p"],
},
}), }),
120, 160,
)} )}
</span>
</dd> </dd>
<dd className="is-size-7 mt-2">
<span className="my-3 mx-2"> {/* Misc Info */}
<dd className="flex flex-wrap items-center gap-2 pt-2 text-xs text-slate-500 dark:text-slate-300">
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4 mr-1 opacity-70" />
{comicvine.volumeInformation.start_year} {comicvine.volumeInformation.start_year}
</span> </span>
{comicvine.volumeInformation.count_of_issues} <span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
ComicVine ID <i className="icon-[solar--book-bold-duotone] w-4 h-4 mr-1 opacity-70" />
{comicvine.id} {comicvine.volumeInformation.count_of_issues} issues
</span>
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70" />
ID: {comicvine.id}
</span>
</dd> </dd>
</dl> </dl>
), )
);
}, },
},
{ {
name: "locg", name: "locg",
content: () => ( content: () => (
@@ -147,23 +156,22 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</h6> </h6>
</dt> </dt>
<dd className="is-size-7"> <dd className="is-size-7">
<span>{ellipsize(locg.description, 120)}</span> <span>{ellipsize(locg?.description || "", 120)}</span>
</dd> </dd>
<dd className="is-size-7 mt-2"> <dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline"> <div className="field is-grouped is-grouped-multiline">
<div className="control"> <div className="control">
<span className="tags"> <span className="tags">
<span className="tag is-success is-light has-text-weight-semibold"> <span className="tag is-success is-light has-text-weight-semibold">
{locg.price} {locg?.price}
</span> </span>
<span className="tag is-success is-light">{locg.pulls}</span> <span className="tag is-success is-light">{locg?.pulls}</span>
</span> </span>
</div> </div>
<div className="control"> <div className="control">
<div className="tags has-addons"> <div className="tags has-addons">
<span className="tag is-primary is-light">rating</span> <span className="tag is-primary is-light">rating</span>
<span className="tag is-info is-light">{locg.rating}</span> <span className="tag is-info is-light">{locg?.rating}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -173,20 +181,19 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
}, },
]; ];
// Find the panel to display
const metadataPanel = find(metadataContentPanel, { const metadataPanel = find(metadataContentPanel, {
name: objectReference, name: objectReference,
}); });
return ( return (
<div className="flex gap-5 my-3"> <div className="flex gap-5 my-3">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation="cover-only"
hasDetails={false} hasDetails={false}
imageStyle={props.imageStyle} imageStyle={props.imageStyle}
cardContainerStyle={{ width: "190px", maxWidth: "230px" }}
/> />
<div>{metadataPanel.content()}</div> <div>{metadataPanel?.content()}</div>
</div> </div>
); );
}; };

View File

@@ -22,7 +22,7 @@ const PopoverButton = ({ content, clickHandler }) => {
onFocus={() => setIsVisible(true)} onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)} onBlur={() => setIsVisible(false)}
aria-describedby="popover" aria-describedby="popover"
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-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" className="flex text-sm 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-1.5 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={clickHandler} onClick={clickHandler}
> >
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "} <i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
@@ -32,7 +32,7 @@ const PopoverButton = ({ content, clickHandler }) => {
<div <div
ref={refs.setFloating} // Apply the floating setter directly to the ref prop ref={refs.setFloating} // Apply the floating setter directly to the ref prop
style={floatingStyles} style={floatingStyles}
className="text-sm bg-slate-400 p-2 rounded-md" className="text-xs bg-slate-400 p-1.5 rounded-md"
role="tooltip" role="tooltip"
> >
{content} {content}

View File

@@ -71,7 +71,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
columns, columns,
manualPagination: true, manualPagination: true,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1, pageCount: sourceData?.length ?? -1,
state: { state: {
pagination, pagination,
}, },

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) => {

View File

@@ -1614,6 +1614,11 @@
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258"
integrity sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw== integrity sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==
"@graphql-typed-document-node/core@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
"@humanwhocodes/config-array@^0.11.13": "@humanwhocodes/config-array@^0.11.13":
version "0.11.14" version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"
@@ -6521,6 +6526,18 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
graphql-request@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-7.2.0.tgz#af4aa25f27a087dd4fc93a4ff54a0f59c4487269"
integrity sha512-0GR7eQHBFYz372u9lxS16cOtEekFlZYB2qOyq8wDvzRmdRSJ0mgUVX1tzNcIzk3G+4NY+mGtSz411wZdeDF/+A==
dependencies:
"@graphql-typed-document-node/core" "^3.2.0"
graphql@^16.0.0:
version "16.11.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.11.0.tgz#96d17f66370678027fdf59b2d4c20b4efaa8a633"
integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==
gunzip-maybe@^1.4.2: gunzip-maybe@^1.4.2:
version "1.4.2" version "1.4.2"
resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac" resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"