Compare commits
6 Commits
0949ebc637
...
graphql-re
| Author | SHA1 | Date | |
|---|---|---|---|
| ea66419f33 | |||
| dc014a08ce | |||
| 90d6562f45 | |||
| 3563fef461 | |||
| 2968987c6b | |||
| 924ffae07e |
@@ -41,6 +41,8 @@
|
||||
"final-form": "^4.20.2",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"graphql": "^16.0.0",
|
||||
"graphql-request": "^7.2.0",
|
||||
"history": "^5.3.0",
|
||||
"html-to-text": "^8.1.0",
|
||||
"i18next": "^23.11.1",
|
||||
|
||||
@@ -5,8 +5,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { difference } from "../../shared/utils/object.utils";
|
||||
@@ -15,44 +14,104 @@ import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
comicObjectId: any;
|
||||
comicObjectId: string;
|
||||
comicObject: 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 = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const socketRef = useRef<Socket>();
|
||||
const queryClient = useQueryClient();
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
||||
const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null);
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
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 issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
// Search timeout duration in milliseconds (30 seconds)
|
||||
const SEARCH_TIMEOUT_MS = 30000;
|
||||
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
// --- Handlers ---
|
||||
const handleResultAdded = ({ result }: any) => {
|
||||
const handleResultAdded = ({ result }: { result: SearchResult }) => {
|
||||
setAirDCPPSearchResults((prev) =>
|
||||
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||
);
|
||||
};
|
||||
|
||||
const handleResultUpdated = ({ result }: any) => {
|
||||
const handleResultUpdated = ({ result }: { result: SearchResult }) => {
|
||||
setAirDCPPSearchResults((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === result.id);
|
||||
if (idx === -1) return prev;
|
||||
@@ -63,45 +122,86 @@ export const AcquisitionPanel = (
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInitiated = (data: any) => {
|
||||
const handleSearchInitiated = (data: { instance: SearchInstanceData }) => {
|
||||
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);
|
||||
};
|
||||
|
||||
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 ---
|
||||
socket.on("searchResultAdded", handleResultAdded);
|
||||
socket.on("searchResultUpdated", handleResultUpdated);
|
||||
socket.on("searchInitiated", handleSearchInitiated);
|
||||
socket.on("searchesSent", handleSearchesSent);
|
||||
socket.on("searchError", handleSearchError);
|
||||
socket.on("searchCompleted", handleSearchCompleted);
|
||||
|
||||
return () => {
|
||||
socket.off("searchResultAdded", handleResultAdded);
|
||||
socket.off("searchResultUpdated", handleResultUpdated);
|
||||
socket.off("searchInitiated", handleSearchInitiated);
|
||||
socket.off("searchesSent", handleSearchesSent);
|
||||
// if you want to fully close the socket:
|
||||
// useStore.getState().disconnectSocket("/manual");
|
||||
socket.off("searchError", handleSearchError);
|
||||
socket.off("searchCompleted", handleSearchCompleted);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [SEARCH_TIMEOUT_MS]);
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
isLoading: isLoadingSettings,
|
||||
isError: isSettingsError,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
const { data: hubs, isLoading: isLoadingHubs } = useQuery({
|
||||
queryKey: ["hubs", settings?.data.directConnect?.client?.host],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
@@ -110,45 +210,72 @@ export const AcquisitionPanel = (
|
||||
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(() => {
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs?.data, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs.data, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery as any);
|
||||
}
|
||||
}, [hubs, sanitizedIssueName]);
|
||||
|
||||
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([]);
|
||||
socketRef.current?.emit("call", "socket.search", {
|
||||
setIsSearching(true);
|
||||
setSearchError(null);
|
||||
|
||||
socketRef.current.emit("call", "socket.search", {
|
||||
query: searchData,
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
config: airDCPPConfig,
|
||||
});
|
||||
};
|
||||
|
||||
const download = async (
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
comicObjectId: String,
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
config: any,
|
||||
searchInstanceId: number,
|
||||
resultId: string,
|
||||
comicObjectId: string,
|
||||
name: string,
|
||||
size: number,
|
||||
type: SearchResult["type"],
|
||||
config: AirDCPPConfig,
|
||||
): Promise<void> => {
|
||||
socketRef.current?.emit(
|
||||
if (!socketRef.current) {
|
||||
console.error("Socket connection not available");
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit(
|
||||
"call",
|
||||
"socket.download",
|
||||
{
|
||||
@@ -160,17 +287,27 @@ export const AcquisitionPanel = (
|
||||
type,
|
||||
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 = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
pattern: `${searchQuery.issueName.trim()}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: [hubs?.data[0].hub_url],
|
||||
hub_urls: [hubs.data[0].hub_url],
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
@@ -180,7 +317,12 @@ export const AcquisitionPanel = (
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
onSubmit={getDCPPSearchResults}
|
||||
initialValues={{
|
||||
@@ -200,20 +342,31 @@ export const AcquisitionPanel = (
|
||||
{...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"
|
||||
placeholder="Type an issue/volume name"
|
||||
disabled={isSearching}
|
||||
/>
|
||||
|
||||
<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"
|
||||
disabled={isSearching}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
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 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++
|
||||
<div className="h-5 w-5 ml-2">
|
||||
<img
|
||||
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -234,26 +387,36 @@ export const AcquisitionPanel = (
|
||||
</article>
|
||||
)}
|
||||
</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 */}
|
||||
{!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="pr-1 pt-1">
|
||||
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{hubs && hubs?.data[0].hub_url}
|
||||
{hubs.data[0].hub_url}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(airDCPPSearchInstance) &&
|
||||
!isEmpty(airDCPPSearchInfo) &&
|
||||
!isNil(hubs) && (
|
||||
{airDCPPSearchInstance &&
|
||||
airDCPPSearchInfo &&
|
||||
hubs?.data && (
|
||||
<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">
|
||||
<dl>
|
||||
<dt>
|
||||
<div className="mb-1">
|
||||
{hubs?.data.map((value, idx: string) => (
|
||||
{hubs.data.map((value: Hub, idx: number) => (
|
||||
<span className="tag is-warning" key={idx}>
|
||||
{value.identity.name}
|
||||
</span>
|
||||
@@ -293,7 +456,7 @@ export const AcquisitionPanel = (
|
||||
|
||||
{/* AirDC++ results */}
|
||||
<div className="">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
{airDCPPSearchResults.length > 0 ? (
|
||||
<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">
|
||||
<thead>
|
||||
@@ -345,9 +508,9 @@ export const AcquisitionPanel = (
|
||||
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||
{users.user.nicks}
|
||||
</span>
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
{users.user.flags.map((flag: string, flagIdx: number) => (
|
||||
<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"
|
||||
>
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||
@@ -378,23 +541,21 @@ export const AcquisitionPanel = (
|
||||
{/* ACTIONS */}
|
||||
<td className="px-2 py-3">
|
||||
<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"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
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={() => {
|
||||
if (airDCPPSearchInstance && airDCPPConfig) {
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
airDCPPConfig,
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!airDCPPSearchInstance || !airDCPPConfig}
|
||||
>
|
||||
Download
|
||||
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||
@@ -406,7 +567,7 @@ export const AcquisitionPanel = (
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
) : !isSearching ? (
|
||||
<div className="">
|
||||
<article
|
||||
role="alert"
|
||||
@@ -432,6 +593,11 @@ export const AcquisitionPanel = (
|
||||
</div>
|
||||
</article>
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
export const Menu = (props): ReactElement => {
|
||||
export const Menu = (props: any): ReactElement => {
|
||||
const {
|
||||
filteredActionOptions,
|
||||
customStyles,
|
||||
@@ -13,11 +13,11 @@ export const Menu = (props): ReactElement => {
|
||||
<Select
|
||||
components={{ Placeholder }}
|
||||
placeholder={
|
||||
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
|
||||
<span className="inline-flex flex-row items-center gap-1.5 pt-1">
|
||||
<div className="w-4 h-4">
|
||||
<i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
|
||||
</div>
|
||||
<div>Select An Action</div>
|
||||
<div className="text-sm">Select An Action</div>
|
||||
</span>
|
||||
}
|
||||
styles={customStyles}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||
|
||||
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||
import { components } from "react-select";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
|
||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
@@ -37,20 +36,30 @@ import { refineQuery } from "filename-parser";
|
||||
interface ComicDetailProps {
|
||||
data: {
|
||||
_id: string;
|
||||
rawFileDetails: {};
|
||||
inferredMetadata: {
|
||||
issue: {};
|
||||
rawFileDetails?: any;
|
||||
inferredMetadata?: {
|
||||
issue?: {
|
||||
year?: string;
|
||||
name?: string;
|
||||
number?: number;
|
||||
subtitle?: string;
|
||||
};
|
||||
};
|
||||
sourcedMetadata: {
|
||||
comicvine: {};
|
||||
locg: {};
|
||||
comicInfo: {};
|
||||
comicvine?: any;
|
||||
locg?: any;
|
||||
comicInfo?: any;
|
||||
};
|
||||
acquisition?: {
|
||||
directconnect?: {
|
||||
downloads?: any[];
|
||||
};
|
||||
torrent?: any[];
|
||||
};
|
||||
acquisition: {};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
userSettings: {};
|
||||
userSettings?: any;
|
||||
}
|
||||
/**
|
||||
* 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)`
|
||||
background: #ccc;
|
||||
`;
|
||||
const afterOpenModal = useCallback((things) => {
|
||||
const afterOpenModal = useCallback((things: any) => {
|
||||
// references are now sync'd and can be accessed.
|
||||
// subtitle.style.color = "#f00";
|
||||
console.log("kolaveri", things);
|
||||
@@ -113,9 +122,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
}, []);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
const contentForSlidingPanel: Record<string, { content: (props?: any) => JSX.Element }> = {
|
||||
CVMatches: {
|
||||
content: (props) => (
|
||||
content: (props?: any) => (
|
||||
<>
|
||||
<div>
|
||||
<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">
|
||||
<p className="">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
{inferredMetadata?.issue ? (
|
||||
<>
|
||||
<span className="">{inferredMetadata.issue.name} </span>
|
||||
<span className=""> # {inferredMetadata.issue.number} </span>
|
||||
@@ -148,9 +157,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
// Actions
|
||||
|
||||
const fetchComicVineMatches = async (
|
||||
searchPayload,
|
||||
issueSearchQuery,
|
||||
seriesSearchQuery,
|
||||
searchPayload: any,
|
||||
issueSearchQuery: any,
|
||||
seriesSearchQuery: any,
|
||||
) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
@@ -170,7 +179,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
},
|
||||
rawFileDetails: searchPayload,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
transformResponse: (r: string) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
// 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) {
|
||||
matches = response.data.results;
|
||||
} 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);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -191,13 +200,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let seriesSearchQuery: any = {};
|
||||
let issueSearchQuery: any = {};
|
||||
|
||||
if (!isUndefined(rawFileDetails)) {
|
||||
issueSearchQuery = refineQuery(rawFileDetails.name);
|
||||
issueSearchQuery = refineQuery((rawFileDetails as any).name);
|
||||
} else if (!isEmpty(comicvine)) {
|
||||
issueSearchQuery = refineQuery(comicvine.name);
|
||||
issueSearchQuery = refineQuery((comicvine as any).name);
|
||||
}
|
||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
@@ -211,30 +220,30 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
// Actions menu options and handler
|
||||
const CVMatchLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||
<span className="inline-flex flex-row items-center gap-1.5">
|
||||
<div className="w-4 h-4">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
|
||||
</div>
|
||||
<div>Match on ComicVine</div>
|
||||
<div className="text-sm">Match on ComicVine</div>
|
||||
</span>
|
||||
);
|
||||
const editLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||
<span className="inline-flex flex-row items-center gap-1.5">
|
||||
<div className="w-4 h-4">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
|
||||
</div>
|
||||
<div>Edit Metadata</div>
|
||||
<div className="text-sm">Edit Metadata</div>
|
||||
</span>
|
||||
);
|
||||
const deleteLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||
<span className="inline-flex flex-row items-center gap-1.5">
|
||||
<div className="w-4 h-4">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
|
||||
</div>
|
||||
<div>Delete Comic</div>
|
||||
<div className="text-sm">Delete Comic</div>
|
||||
</span>
|
||||
);
|
||||
const Placeholder = (props) => {
|
||||
const Placeholder = (props: any) => {
|
||||
return <components.Placeholder {...props} />;
|
||||
};
|
||||
const actionOptions = [
|
||||
@@ -249,7 +258,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const handleActionSelection = (action) => {
|
||||
const handleActionSelection = (action: any) => {
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
@@ -263,23 +272,23 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
}
|
||||
};
|
||||
const customStyles = {
|
||||
menu: (base) => ({
|
||||
menu: (base: any) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
placeholder: (base: any) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
}),
|
||||
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
|
||||
option: (base: any, { data, isDisabled, isFocused, isSelected }: any) => ({
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
singleValue: (base: any) => ({
|
||||
...base,
|
||||
paddingTop: "0.4rem",
|
||||
}),
|
||||
control: (base) => ({
|
||||
control: (base: any) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
color: "black",
|
||||
@@ -289,7 +298,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
// check for the availability of CV metadata
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
||||
!isUndefined(comicvine) && !isUndefined((comicvine as any)?.volumeInformation);
|
||||
|
||||
// check for the availability of rawFileDetails
|
||||
const areRawFileDetailsAvailable =
|
||||
@@ -354,7 +363,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
query={airDCPPQuery}
|
||||
comicObjectId={_id}
|
||||
comicObject={data.data}
|
||||
userSettings={userSettings}
|
||||
settings={userSettings}
|
||||
key={4}
|
||||
/>
|
||||
),
|
||||
@@ -376,8 +385,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
name: "Downloads",
|
||||
icon: (
|
||||
<>
|
||||
{acquisition?.directconnect?.downloads?.length +
|
||||
acquisition?.torrent.length}
|
||||
{(acquisition?.directconnect?.downloads?.length || 0) +
|
||||
(acquisition?.torrent?.length || 0)}
|
||||
</>
|
||||
),
|
||||
content:
|
||||
@@ -414,11 +423,12 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
cardContainerStyle={{ maxWidth: "290px", width: "100%" }}
|
||||
/>
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
!isEmpty((rawFileDetails as any)?.cover) && (
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
@@ -468,7 +478,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||
/>
|
||||
|
||||
<StyledSlidingPanel
|
||||
|
||||
@@ -30,94 +30,125 @@ interface RawFileDetailsProps {
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data;
|
||||
props.data || {};
|
||||
if (!rawFileDetails) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<span className="text-xl">{rawFileDetails.name}</span>
|
||||
<span className="text-xl font-semibold">{rawFileDetails?.name}</span>
|
||||
</p>
|
||||
</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">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Inferred Issue Metadata
|
||||
</dt>
|
||||
<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">
|
||||
MIMEType
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||
{/* 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">
|
||||
|
||||
{/* File Binary Details Section */}
|
||||
<div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<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--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>
|
||||
<dd className="text-sm text-gray-900 dark:text-gray-300 font-mono break-all">
|
||||
{rawFileDetails?.containedIn}/{rawFileDetails?.name}{rawFileDetails?.extension}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
MIME Type
|
||||
</dt>
|
||||
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-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">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
File Size
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||
{/* 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">
|
||||
{rawFileDetails?.mimeType}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||
File Size
|
||||
</dt>
|
||||
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-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)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</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>
|
||||
</dl>
|
||||
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,16 @@ import { useStore } from "../../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
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 { socketIOInstance } = useStore(
|
||||
const { getSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
getSocket: state.getSocket,
|
||||
})),
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -27,21 +31,32 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
// current image
|
||||
const [currentImage, setCurrentImage] = useState([]);
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
|
||||
const [currentImage, setCurrentImage] = useState<string>("");
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
|
||||
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||
useState(false);
|
||||
const constructImagePaths = (data): Array<string> => {
|
||||
const constructImagePaths = (data: string[]): Array<string> => {
|
||||
return data?.map((path: string) =>
|
||||
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
|
||||
);
|
||||
};
|
||||
|
||||
// Listen to the uncompression complete event and orchestrate the final payload
|
||||
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
|
||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||
});
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleUncompressionComplete = (data: any) => {
|
||||
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(() => {
|
||||
let isMounted = true;
|
||||
@@ -58,7 +73,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
},
|
||||
transformResponse: async (responseData) => {
|
||||
const parsedData = JSON.parse(responseData);
|
||||
const paths = parsedData.map((pathObject) => {
|
||||
const paths = parsedData.map((pathObject: any) => {
|
||||
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
|
||||
});
|
||||
const uncompressedArchive = constructImagePaths(paths);
|
||||
@@ -131,7 +146,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
}
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||
imageAnalysis: {
|
||||
content: () => {
|
||||
return (
|
||||
@@ -143,7 +158,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
</pre>
|
||||
) : null}
|
||||
<pre className="font-hasklig mt-3 text-sm">
|
||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -152,7 +167,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
};
|
||||
|
||||
// sliding panel handlers
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||
setSlidingPanelContentId("imageAnalysis");
|
||||
analyzeImage(imageFilePath);
|
||||
setCurrentImage(imageFilePath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import React, { ReactElement, useState, useEffect } from "react";
|
||||
import { map } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
@@ -34,24 +34,35 @@ export const PullList = (): ReactElement => {
|
||||
format(date, "yyyy/M/dd"),
|
||||
);
|
||||
|
||||
// Responsive slides per view
|
||||
const [slidesPerView, setSlidesPerView] = useState(1);
|
||||
|
||||
|
||||
|
||||
// keen slider
|
||||
const [sliderRef, instanceRef] = useKeenSlider(
|
||||
{
|
||||
loop: true,
|
||||
slides: {
|
||||
origin: "auto",
|
||||
number: 15,
|
||||
perView: 5,
|
||||
spacing: 15,
|
||||
},
|
||||
slideChanged() {
|
||||
console.log("slide changed");
|
||||
},
|
||||
const [sliderRef, instanceRef] = useKeenSlider({
|
||||
loop: true,
|
||||
mode: "free-snap",
|
||||
slides: {
|
||||
perView: slidesPerView,
|
||||
spacing: 15,
|
||||
},
|
||||
[
|
||||
// add plugins here
|
||||
],
|
||||
);
|
||||
slideChanged() {
|
||||
console.log("slide changed");
|
||||
},
|
||||
});
|
||||
|
||||
// Update slider when slidesPerView changes
|
||||
useEffect(() => {
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.update({
|
||||
slides: {
|
||||
perView: slidesPerView,
|
||||
spacing: 15,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [slidesPerView, instanceRef]);
|
||||
|
||||
const {
|
||||
data: pullList,
|
||||
@@ -80,79 +91,81 @@ export const PullList = (): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
<Header
|
||||
headerContent="Discover"
|
||||
subHeaderContent={
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://leagueofcomicgeeks.com">
|
||||
League Of Comic Geeks
|
||||
</a>
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
<div className="mx-auto">
|
||||
<Header
|
||||
headerContent="Discover"
|
||||
subHeaderContent={
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://leagueofcomicgeeks.com">
|
||||
League Of Comic Geeks
|
||||
</a>
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link="/pull-list/all/"
|
||||
/>
|
||||
<div className="flex flex-row gap-5 mb-3">
|
||||
{/* select week */}
|
||||
<div className="flex flex-row gap-4 my-3">
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
render={({ handleSubmit }) => (
|
||||
<form>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* week selection for pull list */}
|
||||
<DatePickerDialog
|
||||
inputValue={inputValue}
|
||||
setter={setInputValue}
|
||||
/>
|
||||
{inputValue && (
|
||||
<div className="text-sm">
|
||||
Showing pull list for{" "}
|
||||
<span className="inline-flex mb-2 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">
|
||||
{inputValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link="/pull-list/all/"
|
||||
/>
|
||||
<div className="flex flex-row gap-5 mb-3">
|
||||
{/* select week */}
|
||||
<div className="flex flex-row gap-4 my-3">
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
render={({ handleSubmit }) => (
|
||||
<form>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* week selection for pull list */}
|
||||
<DatePickerDialog
|
||||
inputValue={inputValue}
|
||||
setter={setInputValue}
|
||||
/>
|
||||
{inputValue && (
|
||||
<div className="text-sm">
|
||||
Showing pull list for{" "}
|
||||
<span className="inline-flex mb-2 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">
|
||||
{inputValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuccess && !isLoading && (
|
||||
<div ref={sliderRef} className="keen-slider flex flex-row">
|
||||
<div ref={sliderRef} className="keen-slider">
|
||||
{map(pullList?.data.result, (issue, idx) => {
|
||||
return (
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.coverImageUrl}
|
||||
hasDetails
|
||||
title={ellipsize(issue.issueName, 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 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">
|
||||
{issue.publicationDate}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="flex space-x-1 mb-2 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-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => addToLibrary("locg", issue)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.coverImageUrl}
|
||||
hasDetails
|
||||
title={ellipsize(issue.issueName, 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 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">
|
||||
{issue.publicationDate}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="flex space-x-1 mb-2 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-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => addToLibrary("locg", issue)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,12 +21,13 @@ export const RecentlyImported = (
|
||||
console.log(comics);
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
headerContent="Recently Imported"
|
||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
<div className="mx-auto" style={{ maxWidth: '1400px' }}>
|
||||
<Header
|
||||
headerContent="Recently Imported"
|
||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
<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(
|
||||
(
|
||||
{
|
||||
@@ -125,6 +126,7 @@ export const RecentlyImported = (
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
|
||||
type WantedComicsListProps = {
|
||||
comics: any;
|
||||
@@ -16,107 +17,126 @@ export const WantedComicsList = ({
|
||||
}: WantedComicsListProps): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Header
|
||||
headerContent="Wanted Comics"
|
||||
subHeaderContent="Comics marked as wanted from various sources"
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/wanted"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(
|
||||
comics,
|
||||
({
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
wanted,
|
||||
}) => {
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
<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(
|
||||
comics,
|
||||
(
|
||||
{
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
wanted,
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={_id}
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine.description)) ? (
|
||||
<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--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
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
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine.description)) ? (
|
||||
<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--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(comicvine.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(comicvine.description)
|
||||
.displayName
|
||||
}
|
||||
</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>
|
||||
) : 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>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { format } from "date-fns";
|
||||
import Loader from "react-loader-spinner";
|
||||
@@ -27,12 +27,21 @@ interface IProps {
|
||||
|
||||
export const Import = (props: IProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, socketIOInstance } = useStore(
|
||||
const { importJobQueue, getSocket, setQueryClientRef } = useStore(
|
||||
useShallow((state) => ({
|
||||
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 { 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"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
queryFn: async () => {
|
||||
const response = await axios({
|
||||
method: "GET",
|
||||
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) => {
|
||||
socketIOInstance.emit(
|
||||
const socket = getSocket("/");
|
||||
socket.emit(
|
||||
"call",
|
||||
"socket.setQueueStatus",
|
||||
{
|
||||
queueAction,
|
||||
queueStatus,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
(data: any) => console.log(data),
|
||||
);
|
||||
};
|
||||
/**
|
||||
@@ -246,7 +322,7 @@ export const Import = (props: IProps): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.data.map((jobResult, id) => {
|
||||
{data?.data.map((jobResult: any, id: number) => {
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
|
||||
@@ -152,10 +152,10 @@ export const Library = (): ReactElement => {
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
<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">
|
||||
<i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" />
|
||||
{format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")}
|
||||
</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
@@ -164,23 +164,25 @@ export const Library = (): ReactElement => {
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
{/* DC++ Downloads */}
|
||||
{info.getValue().directconnect?.downloads?.length > 0 ? (
|
||||
<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">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||
<span>
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</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">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
{/* Torrent Downloads */}
|
||||
{info.getValue().torrent.length > 0 ? (
|
||||
<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">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||
<span className="whitespace-nowrap">
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -266,55 +266,80 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
{!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) => {
|
||||
return result.resource_type === "issue" ? (
|
||||
<div
|
||||
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">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
/>
|
||||
{/* IMAGE */}
|
||||
<div className="flex-shrink-0">
|
||||
<Card
|
||||
orientation="cover-only"
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIGHT-SIDE CONTENT */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* TITLE */}
|
||||
<div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
|
||||
{result.volume?.name || <span>No Name</span>}
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.volume.name) ? (
|
||||
result.volume.name
|
||||
) : (
|
||||
<span className="is-size-3">No Name</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SUBMETA */}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{/* Cover Date Token */}
|
||||
{result.cover_date && (
|
||||
<p>
|
||||
<span className="tag is-light">Cover date</span>
|
||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||
</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="pr-1 pt-1">
|
||||
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4"></i>
|
||||
</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}>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
<p className="text-sm">
|
||||
{/* LINK */}
|
||||
<a
|
||||
href={result.api_detail_url}
|
||||
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
|
||||
>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
{result.description && (
|
||||
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
selectors: ["p", "div"],
|
||||
},
|
||||
convert(result.description ?? "", {
|
||||
baseElements: { selectors: ["p", "div"] },
|
||||
}),
|
||||
320,
|
||||
300,
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
)}
|
||||
|
||||
{/* CTA BUTTON */}
|
||||
{result.volume.name && (
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<PopoverButton
|
||||
content={`This will add ${result.volume.name} to your wanted list.`}
|
||||
content={`This will add ${result?.volume?.name} to your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
@@ -325,114 +350,109 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
result.resource_type === "volume" && (
|
||||
<div
|
||||
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">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
/>
|
||||
{/* LEFT COLUMN: COVER */}
|
||||
<Card
|
||||
orientation="cover-only"
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
cardContainerStyle={{
|
||||
width: "120px",
|
||||
maxWidth: "150px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* TITLE */}
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{result.name || <span>No Name</span>}
|
||||
{result.start_year && <> ({result.start_year})</>}
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.name) ? (
|
||||
result.name
|
||||
) : (
|
||||
<span className="text-xl">No Name</span>
|
||||
)}
|
||||
{result.start_year && <> ({result.start_year})</>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* issue count */}
|
||||
{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 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{/* TOKENS */}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{/* ISSUE COUNT */}
|
||||
{result.count_of_issues && (
|
||||
<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-minimalistic-bold-duotone] w-4 h-4" />
|
||||
</span>
|
||||
<span>
|
||||
{t("issueWithCount", {
|
||||
count: result.count_of_issues,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{t("issueWithCount", {
|
||||
count: result.count_of_issues,
|
||||
})}
|
||||
</span>
|
||||
{/* 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>
|
||||
</div>
|
||||
<span>
|
||||
{
|
||||
detectIssueTypes(result.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{/* 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">
|
||||
{
|
||||
detectIssueTypes(result.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<span className="tag is-warning">{result.id}</span>
|
||||
<p>
|
||||
<a href={result.api_detail_url}>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
</p>
|
||||
{/* LINK */}
|
||||
<a
|
||||
href={result.api_detail_url}
|
||||
className="text-sm text-blue-500 underline mt-2 break-all"
|
||||
>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
|
||||
{/* description */}
|
||||
<p className="text-sm">
|
||||
{/* DESCRIPTION */}
|
||||
{result.description && (
|
||||
<p className="text-sm mt-2 text-slate-700 dark:text-slate-200 break-words line-clamp-3">
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
selectors: ["p", "div"],
|
||||
},
|
||||
baseElements: { selectors: ["p", "div"] },
|
||||
}),
|
||||
320,
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
)}
|
||||
{result.name ? (
|
||||
<div className="mt-4 justify-self-end">
|
||||
<PopoverButton
|
||||
content={`Adding this volume will add ${t(
|
||||
"issueWithCount",
|
||||
{
|
||||
count: result.count_of_issues,
|
||||
},
|
||||
)} to your wanted list.`}
|
||||
content={`This will add ${result.count_of_issues} issues your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
comicObject: result,
|
||||
markEntireVolumeWanted: true,
|
||||
resourceType: "volume",
|
||||
markEntireVolumeWanted: false,
|
||||
resourceType: "issue",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,37 +1,87 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||
import SearchBar from "../Library/SearchBar";
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { gql, GraphQLClient } from "graphql-request";
|
||||
import T2Table from "../shared/T2Table";
|
||||
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 {
|
||||
data: wantedComics,
|
||||
isSuccess,
|
||||
isFetched,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||
method: "POST",
|
||||
data: {
|
||||
query: {},
|
||||
/**
|
||||
* GraphQL client for interfacing with Moleculer Apollo server.
|
||||
*/
|
||||
const client = new GraphQLClient("http://localhost:3000/graphql");
|
||||
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: 0,
|
||||
},
|
||||
type: "wanted",
|
||||
trigger: "wantedComicsPage",
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* GraphQL query to fetch wanted comics.
|
||||
*/
|
||||
const WANTED_COMICS_QUERY = gql`
|
||||
query {
|
||||
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"],
|
||||
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 = [
|
||||
{
|
||||
header: "Comic Information",
|
||||
@@ -40,11 +90,11 @@ export const WantedComics = (props): ReactElement => {
|
||||
header: "Details",
|
||||
id: "comicDetails",
|
||||
minWidth: 350,
|
||||
accessorFn: (data) => data,
|
||||
cell: (value) => {
|
||||
console.log("ASDASd", value);
|
||||
const row = value.getValue()._source;
|
||||
return row && <MetadataPanel data={row} />;
|
||||
accessorFn: (data: Comic) => data,
|
||||
cell: (value: any) => {
|
||||
const row = value.getValue();
|
||||
console.log("Comic row data:", row);
|
||||
return row ? <MetadataPanel data={row} /> : null;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -55,148 +105,73 @@ export const WantedComics = (props): ReactElement => {
|
||||
{
|
||||
header: "Files",
|
||||
align: "right",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (props) => {
|
||||
const {
|
||||
directconnect: { downloads },
|
||||
} = props.getValue();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
// flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{downloads.length > 0 ? (
|
||||
<span className="tag is-warning">{downloads.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
accessorFn: (row: Comic) =>
|
||||
row?.acquisition?.directconnect?.downloads || [],
|
||||
cell: (props: any) => {
|
||||
const downloads = props.getValue();
|
||||
return downloads?.length > 0 ? (
|
||||
<span className="tag is-warning">{downloads.length}</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Download Details",
|
||||
id: "downloadDetails",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (data) => (
|
||||
accessorFn: (row: Comic) =>
|
||||
row?.acquisition?.directconnect?.downloads || [],
|
||||
cell: (data: any) => (
|
||||
<ol>
|
||||
{data.getValue().directconnect.downloads.map((download, idx) => {
|
||||
return (
|
||||
<li className="is-size-7" key={idx}>
|
||||
{download.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{data.getValue()?.map((download: any, idx: number) => (
|
||||
<li className="is-size-7" key={idx}>
|
||||
{download.name}
|
||||
</li>
|
||||
))}
|
||||
</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 (
|
||||
<div className="">
|
||||
<section className="">
|
||||
<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="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Wanted Comics
|
||||
</h1>
|
||||
<section>
|
||||
<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="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
Wanted Comics
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse through comics you marked as "wanted."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
Browse through comics you marked as "wanted."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess && wantedComics?.data.hits?.hits ? (
|
||||
<div>
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={wantedComics?.data.hits.hits}
|
||||
totalPages={wantedComics?.data.hits.hits.length}
|
||||
columns={columnData}
|
||||
paginationHandlers={{
|
||||
nextPage: () => {},
|
||||
previousPage: () => {},
|
||||
}}
|
||||
// rowClickHandler={navigateToComicDetail}
|
||||
/>
|
||||
{/* pagination controls */}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? <div>Loading...</div> : null}
|
||||
{isError ? (
|
||||
<div>An error occurred while retrieving the pull list.</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="animate-pulse p-4 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="h-24 bg-slate-300 dark:bg-slate-600 rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isError && <div>Error fetching wanted comics. {error?.message}</div>}
|
||||
{isSuccess && data?.comics?.length > 0 ? (
|
||||
<T2Table
|
||||
sourceData={data.comics}
|
||||
totalPages={data.comics.length}
|
||||
columns={columnData}
|
||||
paginationHandlers={{}}
|
||||
/>
|
||||
) : isSuccess ? (
|
||||
<div>No comics found.</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ interface ICardProps {
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: PropTypes.object;
|
||||
imageStyle?: PropTypes.object;
|
||||
cardContainerStyle?: any;
|
||||
imageStyle?: any;
|
||||
}
|
||||
|
||||
const renderCard = (props: ICardProps): ReactElement => {
|
||||
@@ -87,7 +87,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
<img
|
||||
alt="Home"
|
||||
src={props.imageUrl}
|
||||
className="rounded-t-md object-cover"
|
||||
className="rounded-t-md object-cover w-full"
|
||||
/>
|
||||
|
||||
{props.title ? (
|
||||
@@ -140,14 +140,31 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
{/* thumbnail */}
|
||||
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
|
||||
<img src={props.imageUrl} />
|
||||
</div>
|
||||
</>
|
||||
<div
|
||||
className={`rounded-2xl overflow-hidden shadow-md bg-white dark:bg-slate-800 ${
|
||||
props.cardContainerStyle?.height ? "" : "aspect-[2/3]"
|
||||
}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
<img src={props.imageUrl} alt="Comic cover" style={imageStyle} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "card-with-info-panel":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ellipsize from "ellipsize";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { Card } from "../shared/Carda";
|
||||
@@ -7,26 +6,48 @@ import { convert } from "html-to-text";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { find, isUndefined } from "lodash";
|
||||
|
||||
interface IMetadatPanelProps {
|
||||
value: any;
|
||||
children: any;
|
||||
imageStyle: any;
|
||||
titleStyle: any;
|
||||
tagsStyle: any;
|
||||
containerStyle: any;
|
||||
/**
|
||||
* Props for the MetadataPanel component.
|
||||
*/
|
||||
interface MetadataPanelProps {
|
||||
/**
|
||||
* Comic metadata object passed into the panel.
|
||||
*/
|
||||
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 {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
sourcedMetadata: { comicvine, locg },
|
||||
} = props.data;
|
||||
|
||||
const { issueName, url, objectReference } = determineCoverFile({
|
||||
comicvine,
|
||||
locg,
|
||||
rawFileDetails,
|
||||
});
|
||||
|
||||
const metadataContentPanel = [
|
||||
{
|
||||
name: "rawFileDetails",
|
||||
@@ -43,48 +64,29 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
{/* Issue number */}
|
||||
{inferredMetadata.issue.number && (
|
||||
<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="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
<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--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70"></i>
|
||||
<span>{inferredMetadata.issue.number}</span>
|
||||
</span>
|
||||
</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">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
<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}
|
||||
</span>
|
||||
|
||||
{/* 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>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
<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--database-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
|
||||
{/* Uncompressed version available? */}
|
||||
{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="pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5 pr-1 pt-1" />
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
@@ -94,49 +96,56 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
|
||||
{
|
||||
name: "comicvine",
|
||||
content: () =>
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation) && (
|
||||
<dl>
|
||||
<dt>
|
||||
<h6
|
||||
className="name has-text-weight-medium mb-1"
|
||||
style={props.titleStyle}
|
||||
>
|
||||
{ellipsize(issueName, 18)}
|
||||
</h6>
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="is-size-7">
|
||||
Is a part of{" "}
|
||||
<span className="has-text-weight-semibold">
|
||||
{comicvine.volumeInformation.name}
|
||||
content: () => {
|
||||
return (
|
||||
!isUndefined(comicvine?.volumeInformation) && (
|
||||
<dl className="space-y-1 text-sm text-slate-700 dark:text-slate-200">
|
||||
{/* Title */}
|
||||
<dt className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{ellipsize(issueName, 28)}
|
||||
</dt>
|
||||
|
||||
{/* Volume Name */}
|
||||
<dd>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Part of{" "}
|
||||
<span className="font-medium text-slate-800 dark:text-white">
|
||||
{comicvine.volumeInformation.name}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
<dd className="is-size-7">
|
||||
<span>
|
||||
</dd>
|
||||
|
||||
{/* Description */}
|
||||
<dd className="text-slate-600 dark:text-slate-300">
|
||||
{ellipsize(
|
||||
convert(comicvine.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
convert(comicvine.description || "", {
|
||||
baseElements: { selectors: ["p"] },
|
||||
}),
|
||||
120,
|
||||
160,
|
||||
)}
|
||||
</span>
|
||||
</dd>
|
||||
<dd className="is-size-7 mt-2">
|
||||
<span className="my-3 mx-2">
|
||||
{comicvine.volumeInformation.start_year}
|
||||
</span>
|
||||
{comicvine.volumeInformation.count_of_issues}
|
||||
ComicVine ID
|
||||
{comicvine.id}
|
||||
</dd>
|
||||
</dl>
|
||||
),
|
||||
</dd>
|
||||
|
||||
{/* 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}
|
||||
</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--book-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||
{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>
|
||||
</dl>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "locg",
|
||||
content: () => (
|
||||
@@ -147,23 +156,22 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
</h6>
|
||||
</dt>
|
||||
<dd className="is-size-7">
|
||||
<span>{ellipsize(locg.description, 120)}</span>
|
||||
<span>{ellipsize(locg?.description || "", 120)}</span>
|
||||
</dd>
|
||||
|
||||
<dd className="is-size-7 mt-2">
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control">
|
||||
<span className="tags">
|
||||
<span className="tag is-success is-light has-text-weight-semibold">
|
||||
{locg.price}
|
||||
{locg?.price}
|
||||
</span>
|
||||
<span className="tag is-success is-light">{locg.pulls}</span>
|
||||
<span className="tag is-success is-light">{locg?.pulls}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<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>
|
||||
@@ -173,20 +181,19 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
},
|
||||
];
|
||||
|
||||
// Find the panel to display
|
||||
const metadataPanel = find(metadataContentPanel, {
|
||||
name: objectReference,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 my-3">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
orientation="cover-only"
|
||||
hasDetails={false}
|
||||
imageStyle={props.imageStyle}
|
||||
cardContainerStyle={{ width: "190px", maxWidth: "230px" }}
|
||||
/>
|
||||
<div>{metadataPanel.content()}</div>
|
||||
<div>{metadataPanel?.content()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const PopoverButton = ({ content, clickHandler }) => {
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
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}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||
@@ -32,7 +32,7 @@ const PopoverButton = ({ content, clickHandler }) => {
|
||||
<div
|
||||
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
|
||||
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"
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
columns,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
pageCount: sourceData.length ?? -1,
|
||||
pageCount: sourceData?.length ?? -1,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
|
||||
@@ -2,17 +2,16 @@ import { create } from "zustand";
|
||||
import io, { Socket } from "socket.io-client";
|
||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||
import { isNil } from "lodash";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.min.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Type for global state
|
||||
interface StoreState {
|
||||
socketInstances: Record<string, Socket>;
|
||||
getSocket: (namespace?: string) => Socket;
|
||||
disconnectSocket: (namespace: string) => void;
|
||||
queryClientRef: { current: any } | null;
|
||||
setQueryClientRef: (ref: any) => void;
|
||||
|
||||
comicvine: {
|
||||
scrapingStatus: string;
|
||||
@@ -32,6 +31,8 @@ interface StoreState {
|
||||
|
||||
export const useStore = create<StoreState>((set, get) => ({
|
||||
socketInstances: {},
|
||||
queryClientRef: null,
|
||||
setQueryClientRef: (ref: any) => set({ queryClientRef: ref }),
|
||||
|
||||
getSocket: (namespace = "/") => {
|
||||
const fullNamespace = namespace === "/" ? "" : namespace;
|
||||
@@ -97,7 +98,10 @@ export const useStore = create<StoreState>((set, get) => ({
|
||||
status: "drained",
|
||||
},
|
||||
}));
|
||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||
const queryClientRef = get().queryClientRef;
|
||||
if (queryClientRef?.current) {
|
||||
queryClientRef.current.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("CV_SCRAPING_STATUS", (data) => {
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@@ -1614,6 +1614,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258"
|
||||
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":
|
||||
version "0.11.14"
|
||||
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"
|
||||
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:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"
|
||||
|
||||
Reference in New Issue
Block a user