🧦 Refactored socket store in zustand
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useEffect } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Navbar2 } from "./shared/Navbar2";
|
import { Navbar2 } from "./shared/Navbar2";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import "../assets/scss/App.scss";
|
import "../assets/scss/App.scss";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
|
||||||
export const App = (): ReactElement => {
|
export const App = (): ReactElement => {
|
||||||
|
useEffect(() => {
|
||||||
|
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar2 />
|
<Navbar2 />
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
ReactElement,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
@@ -10,6 +16,7 @@ import { useShallow } from "zustand/react/shallow";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
interface IAcquisitionPanelProps {
|
interface IAcquisitionPanelProps {
|
||||||
query: any;
|
query: any;
|
||||||
@@ -21,32 +28,65 @@ interface IAcquisitionPanelProps {
|
|||||||
export const AcquisitionPanel = (
|
export const AcquisitionPanel = (
|
||||||
props: IAcquisitionPanelProps,
|
props: IAcquisitionPanelProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
const { socketIOInstance } = useStore(
|
const socketRef = useRef<Socket>();
|
||||||
useShallow((state) => ({
|
const queryClient = useQueryClient();
|
||||||
socketIOInstance: state.socketIOInstance,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SearchData {
|
const [dcppQuery, setDcppQuery] = useState({});
|
||||||
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
||||||
hub_urls: string[] | undefined | null;
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
priority: PriorityEnum;
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
||||||
}
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
||||||
interface SearchResult {
|
|
||||||
id: string;
|
const { comicObjectId } = props;
|
||||||
// Add other properties as needed
|
const issueName = props.query.issue.name || "";
|
||||||
slots: any;
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
type: any;
|
|
||||||
users: any;
|
useEffect(() => {
|
||||||
name: string;
|
const socket = useStore.getState().getSocket("manual");
|
||||||
dupe: Boolean;
|
socketRef.current = socket;
|
||||||
size: number;
|
|
||||||
}
|
// --- Handlers ---
|
||||||
|
const handleResultAdded = ({ result }: any) => {
|
||||||
|
setAirDCPPSearchResults((prev) =>
|
||||||
|
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultUpdated = ({ result }: any) => {
|
||||||
|
setAirDCPPSearchResults((prev) => {
|
||||||
|
const idx = prev.findIndex((r) => r.id === result.id);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = result;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchInitiated = (data: any) => {
|
||||||
|
setAirDCPPSearchInstance(data.instance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchesSent = (data: any) => {
|
||||||
|
setAirDCPPSearchInfo(data.searchInfo);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Subscribe once ---
|
||||||
|
socket.on("searchResultAdded", handleResultAdded);
|
||||||
|
socket.on("searchResultUpdated", handleResultUpdated);
|
||||||
|
socket.on("searchInitiated", handleSearchInitiated);
|
||||||
|
socket.on("searchesSent", handleSearchesSent);
|
||||||
|
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearch = (searchQuery) => {
|
|
||||||
// Use the already connected socket instance to emit events
|
|
||||||
socketIOInstance.emit("initiateSearch", searchQuery);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
data: settings,
|
data: settings,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -59,9 +99,7 @@ export const AcquisitionPanel = (
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
/**
|
|
||||||
* Get the hubs list from an AirDCPP Socket
|
|
||||||
*/
|
|
||||||
const { data: hubs } = useQuery({
|
const { data: hubs } = useQuery({
|
||||||
queryKey: ["hubs"],
|
queryKey: ["hubs"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -74,24 +112,8 @@ export const AcquisitionPanel = (
|
|||||||
}),
|
}),
|
||||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||||
});
|
});
|
||||||
const { comicObjectId } = props;
|
|
||||||
const issueName = props.query.issue.name || "";
|
|
||||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
|
||||||
|
|
||||||
const [dcppQuery, setDcppQuery] = useState({});
|
|
||||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<
|
|
||||||
SearchResult[]
|
|
||||||
>([]);
|
|
||||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
|
||||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
|
||||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
|
||||||
// Pre-populate the search input with the search string, so that
|
|
||||||
// all the user has to do is hit "Search AirDC++" to perform a search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// AirDC++ search query
|
|
||||||
const dcppSearchQuery = {
|
const dcppSearchQuery = {
|
||||||
query: {
|
query: {
|
||||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
@@ -101,67 +123,22 @@ export const AcquisitionPanel = (
|
|||||||
priority: 5,
|
priority: 5,
|
||||||
};
|
};
|
||||||
setDcppQuery(dcppSearchQuery);
|
setDcppQuery(dcppSearchQuery);
|
||||||
}, []);
|
}, [hubs, sanitizedIssueName]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Method to perform a search via an AirDC++ websocket
|
|
||||||
* @param {SearchData} data - a SearchData query
|
|
||||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
|
||||||
*/
|
|
||||||
const search = async (searchData: any) => {
|
const search = async (searchData: any) => {
|
||||||
setAirDCPPSearchResults([]);
|
setAirDCPPSearchResults([]);
|
||||||
socketIOInstance.emit("call", "socket.search", {
|
socketRef.current?.emit("call", "socket.search", {
|
||||||
query: searchData,
|
query: searchData,
|
||||||
|
namespace: "/manual",
|
||||||
config: {
|
config: {
|
||||||
protocol: `ws`,
|
protocol: `ws`,
|
||||||
// hostname: `192.168.1.119:5600`,
|
hostname: `192.168.1.119:5600`,
|
||||||
hostname: `127.0.0.1:5600`,
|
username: `admin`,
|
||||||
username: `user`,
|
password: `password`,
|
||||||
password: `pass`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
socketIOInstance.on("searchResultAdded", ({ result }: any) => {
|
|
||||||
setAirDCPPSearchResults((previousState) => {
|
|
||||||
const exists = previousState.some((item) => result.id === item.id);
|
|
||||||
if (!exists) {
|
|
||||||
return [...previousState, result];
|
|
||||||
}
|
|
||||||
return previousState;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socketIOInstance.on("searchResultUpdated", ({ result }: any) => {
|
|
||||||
// ...update properties of the existing result in the UI
|
|
||||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
|
||||||
(bundle) => bundle.id === result.id,
|
|
||||||
);
|
|
||||||
const updatedState = [...airDCPPSearchResults];
|
|
||||||
if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) {
|
|
||||||
updatedState[bundleToUpdateIndex] = result;
|
|
||||||
}
|
|
||||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socketIOInstance.on("searchInitiated", (data) => {
|
|
||||||
setAirDCPPSearchInstance(data.instance);
|
|
||||||
});
|
|
||||||
socketIOInstance.on("searchesSent", (data) => {
|
|
||||||
setAirDCPPSearchInfo(data.searchInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Method to download a bundle associated with a search result from AirDC++
|
|
||||||
* @param {Number} searchInstanceId - description
|
|
||||||
* @param {String} resultId - description
|
|
||||||
* @param {String} comicObjectId - description
|
|
||||||
* @param {String} name - description
|
|
||||||
* @param {Number} size - description
|
|
||||||
* @param {any} type - description
|
|
||||||
* @param {any} config - description
|
|
||||||
* @returns {void} - description
|
|
||||||
*/
|
|
||||||
const download = async (
|
const download = async (
|
||||||
searchInstanceId: Number,
|
searchInstanceId: Number,
|
||||||
resultId: String,
|
resultId: String,
|
||||||
@@ -171,7 +148,7 @@ export const AcquisitionPanel = (
|
|||||||
type: any,
|
type: any,
|
||||||
config: any,
|
config: any,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
socketIOInstance.emit(
|
socketRef.current?.emit(
|
||||||
"call",
|
"call",
|
||||||
"socket.download",
|
"socket.download",
|
||||||
{
|
{
|
||||||
@@ -186,6 +163,7 @@ export const AcquisitionPanel = (
|
|||||||
(data: any) => console.log(data),
|
(data: any) => console.log(data),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDCPPSearchResults = async (searchQuery) => {
|
const getDCPPSearchResults = async (searchQuery) => {
|
||||||
const manualQuery = {
|
const manualQuery = {
|
||||||
query: {
|
query: {
|
||||||
@@ -316,20 +294,20 @@ export const AcquisitionPanel = (
|
|||||||
{/* AirDC++ results */}
|
{/* AirDC++ results */}
|
||||||
<div className="">
|
<div className="">
|
||||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
|
<div className="overflow-x-auto max-w-full mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
|
||||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
|
<table className="w-full table-auto divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
Slots
|
Slots
|
||||||
</th>
|
</th>
|
||||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -337,118 +315,93 @@ export const AcquisitionPanel = (
|
|||||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||||
{map(
|
{map(
|
||||||
airDCPPSearchResults,
|
airDCPPSearchResults,
|
||||||
({ dupe, type, name, id, slots, users, size }, idx) => {
|
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||||
return (
|
<tr
|
||||||
<tr
|
key={idx}
|
||||||
key={idx}
|
className={
|
||||||
className={
|
!isNil(dupe)
|
||||||
!isNil(dupe)
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
? "bg-gray-100 dark:bg-gray-700"
|
: "text-sm"
|
||||||
: "w-fit text-sm"
|
}
|
||||||
}
|
>
|
||||||
>
|
{/* NAME */}
|
||||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
{type.id === "directory" ? (
|
{type.id === "directory" && (
|
||||||
<i className="fas fa-folder"></i>
|
<i className="fas fa-folder mr-1"></i>
|
||||||
) : null}
|
)}
|
||||||
{ellipsize(name, 70)}
|
{ellipsize(name, 45)}
|
||||||
</p>
|
</p>
|
||||||
|
<dl>
|
||||||
<dl>
|
<dd>
|
||||||
<dd>
|
<div className="inline-flex flex-wrap gap-1">
|
||||||
<div className="inline-flex flex-row gap-2">
|
{!isNil(dupe) && (
|
||||||
{!isNil(dupe) ? (
|
<span 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">
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||||
<span className="pr-1 pt-1">
|
Dupe
|
||||||
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
|
||||||
Dupe
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Nicks */}
|
|
||||||
<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--user-rounded-bold-duotone] w-5 h-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
|
||||||
{users.user.nicks}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
{/* Flags */}
|
)}
|
||||||
{users.user.flags.map((flag, idx) => (
|
<span 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">
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||||
<span className="pr-1 pt-1">
|
{users.user.nicks}
|
||||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
|
</span>
|
||||||
</span>
|
{users.user.flags.map((flag, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
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>
|
||||||
|
{flag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
{/* TYPE */}
|
||||||
{flag}
|
<td className="px-2 py-3">
|
||||||
</span>
|
<span 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">
|
||||||
</span>
|
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||||
))}
|
{type.str}
|
||||||
</div>
|
</span>
|
||||||
</dd>
|
</td>
|
||||||
</dl>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{/* 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">
|
{/* SLOTS */}
|
||||||
{type.str}
|
<td className="px-2 py-3">
|
||||||
</span>
|
<span 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">
|
||||||
</span>
|
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||||
</td>
|
{slots.total} slots; {slots.free} free
|
||||||
<td className="px-2">
|
</span>
|
||||||
{/* Slots */}
|
</td>
|
||||||
<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--settings-minimalistic-bold-duotone] w-5 h-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
{/* ACTIONS */}
|
||||||
{slots.total} slots; {slots.free} free
|
<td className="px-2 py-3">
|
||||||
</span>
|
<button
|
||||||
</span>
|
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"
|
||||||
</td>
|
onClick={() =>
|
||||||
<td className="px-2">
|
download(
|
||||||
<button
|
airDCPPSearchInstance.id,
|
||||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
id,
|
||||||
onClick={() =>
|
comicObjectId,
|
||||||
download(
|
name,
|
||||||
airDCPPSearchInstance.id,
|
size,
|
||||||
id,
|
type,
|
||||||
comicObjectId,
|
{
|
||||||
name,
|
protocol: `ws`,
|
||||||
size,
|
hostname: `192.168.1.119:5600`,
|
||||||
type,
|
username: `admin`,
|
||||||
{
|
password: `password`,
|
||||||
protocol: `ws`,
|
},
|
||||||
hostname: `192.168.1.119:5600`,
|
)
|
||||||
username: `admin`,
|
}
|
||||||
password: `password`,
|
>
|
||||||
},
|
Download
|
||||||
)
|
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||||
}
|
</button>
|
||||||
>
|
</td>
|
||||||
<span className="text-xs">Download</span>
|
</tr>
|
||||||
<span className="w-5 h-5">
|
),
|
||||||
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,161 +1,173 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { isNil } from "lodash";
|
import io, { Socket } from "socket.io-client";
|
||||||
import io from "socket.io-client";
|
|
||||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||||
import { produce } from "immer";
|
import { isNil } from "lodash";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import "react-toastify/dist/ReactToastify.min.css";
|
import "react-toastify/dist/ReactToastify.min.css";
|
||||||
|
|
||||||
/* Broadly, this file sets up:
|
const queryClient = new QueryClient();
|
||||||
* 1. The zustand-based global client state
|
|
||||||
* 2. socket.io client
|
// Type for global state
|
||||||
*/
|
interface StoreState {
|
||||||
export const useStore = create((set, get) => ({
|
socketInstances: Record<string, Socket>;
|
||||||
// Socket.io state
|
getSocket: (namespace?: string) => Socket;
|
||||||
socketIOInstance: {},
|
disconnectSocket: (namespace: string) => void;
|
||||||
// ComicVine Scraping status
|
|
||||||
|
comicvine: {
|
||||||
|
scrapingStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
importJobQueue: {
|
||||||
|
successfulJobCount: number;
|
||||||
|
failedJobCount: number;
|
||||||
|
status: string | undefined;
|
||||||
|
mostRecentImport: string | null;
|
||||||
|
|
||||||
|
setStatus: (status: string) => void;
|
||||||
|
setJobCount: (jobType: string, count: number) => void;
|
||||||
|
setMostRecentImport: (fileName: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<StoreState>((set, get) => ({
|
||||||
|
socketInstances: {},
|
||||||
|
|
||||||
|
getSocket: (namespace = "/") => {
|
||||||
|
const fullNamespace = namespace === "/" ? "" : namespace;
|
||||||
|
const existing = get().socketInstances[namespace];
|
||||||
|
|
||||||
|
if (existing && existing.connected) return existing;
|
||||||
|
|
||||||
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
const socket = io(`${SOCKET_BASE_URI}${fullNamespace}`, {
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
query: { sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log(`✅ Connected to ${namespace}:`, socket.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
socket.emit("call", "socket.resumeSession", { sessionId, namespace });
|
||||||
|
} else {
|
||||||
|
socket.on("sessionInitialized", (id) => {
|
||||||
|
localStorage.setItem("sessionId", id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
||||||
|
const { completedJobCount, failedJobCount, queueStatus } = data;
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
successfulJobCount: completedJobCount,
|
||||||
|
failedJobCount,
|
||||||
|
status: queueStatus,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_COVER_EXTRACTED", ({ completedJobCount, importResult }) => {
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
successfulJobCount: completedJobCount,
|
||||||
|
mostRecentImport: importResult.data.rawFileDetails.name,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_COVER_EXTRACTION_FAILED", ({ failedJobCount }) => {
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
failedJobCount,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_IMPORT_QUEUE_DRAINED", () => {
|
||||||
|
localStorage.removeItem("sessionId");
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
status: "drained",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("CV_SCRAPING_STATUS", (data) => {
|
||||||
|
set((state) => ({
|
||||||
|
comicvine: {
|
||||||
|
...state.comicvine,
|
||||||
|
scrapingStatus: data.message,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("searchResultsAvailable", (data) => {
|
||||||
|
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
socketInstances: {
|
||||||
|
...state.socketInstances,
|
||||||
|
[namespace]: socket,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectSocket: (namespace: string) => {
|
||||||
|
const socket = get().socketInstances[namespace];
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
set((state) => {
|
||||||
|
const { [namespace]: _, ...rest } = state.socketInstances;
|
||||||
|
return { socketInstances: rest };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
comicvine: {
|
comicvine: {
|
||||||
scrapingStatus: "",
|
scrapingStatus: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Import job queue and associated statuses
|
|
||||||
importJobQueue: {
|
importJobQueue: {
|
||||||
successfulJobCount: 0,
|
successfulJobCount: 0,
|
||||||
failedJobCount: 0,
|
failedJobCount: 0,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
setStatus: (status: string) =>
|
|
||||||
set(
|
|
||||||
produce((draftState) => {
|
|
||||||
draftState.importJobQueue.status = status;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
setJobCount: (jobType: string, count: Number) => {
|
|
||||||
switch (jobType) {
|
|
||||||
case "successful":
|
|
||||||
set(
|
|
||||||
produce((draftState) => {
|
|
||||||
draftState.importJobQueue.successfulJobCount = count;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "failed":
|
|
||||||
set(
|
|
||||||
produce((draftState) => {
|
|
||||||
draftState.importJobQueue.failedJobCount = count;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mostRecentImport: null,
|
mostRecentImport: null,
|
||||||
setMostRecentImport: (fileName: string) => {
|
|
||||||
set(
|
setStatus: (status: string) =>
|
||||||
produce((state) => {
|
set((state) => ({
|
||||||
state.importJobQueue.mostRecentImport = fileName;
|
importJobQueue: {
|
||||||
}),
|
...state.importJobQueue,
|
||||||
);
|
status,
|
||||||
},
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setJobCount: (jobType: string, count: number) =>
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
...(jobType === "successful"
|
||||||
|
? { successfulJobCount: count }
|
||||||
|
: { failedJobCount: count }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setMostRecentImport: (fileName: string) =>
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
mostRecentImport: fileName,
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { getState, setState } = useStore;
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
/** Socket.IO initialization **/
|
|
||||||
// 1. Fetch sessionId from localStorage
|
|
||||||
const sessionId = localStorage.getItem("sessionId");
|
|
||||||
// 2. socket.io instantiation
|
|
||||||
const socketIOInstance = io(SOCKET_BASE_URI, {
|
|
||||||
transports: ["websocket"],
|
|
||||||
withCredentials: true,
|
|
||||||
query: { sessionId },
|
|
||||||
});
|
|
||||||
// 3. Set the instance in global state
|
|
||||||
setState({
|
|
||||||
socketIOInstance,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Socket.io-based session restoration
|
|
||||||
if (!isNil(sessionId)) {
|
|
||||||
// 1. Resume the session
|
|
||||||
socketIOInstance.emit(
|
|
||||||
"call",
|
|
||||||
"socket.resumeSession",
|
|
||||||
{
|
|
||||||
namespace: "/",
|
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
(data) => console.log(data),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 1. Inititalize the session and persist the sessionId to localStorage
|
|
||||||
socketIOInstance.on("sessionInitialized", (sessionId) => {
|
|
||||||
localStorage.setItem("sessionId", sessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 2. If a job is in progress, restore the job counts and persist those to global state
|
|
||||||
socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
|
||||||
console.log("Active import in progress detected; restoring counts...");
|
|
||||||
const { completedJobCount, failedJobCount, queueStatus } = data;
|
|
||||||
setState((state) => ({
|
|
||||||
importJobQueue: {
|
|
||||||
...state.importJobQueue,
|
|
||||||
successfulJobCount: completedJobCount,
|
|
||||||
failedJobCount,
|
|
||||||
status: queueStatus,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1a. Act on each comic issue successfully imported/failed, as indicated
|
|
||||||
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
|
|
||||||
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
|
|
||||||
const { completedJobCount, importResult } = data;
|
|
||||||
console.log(importResult);
|
|
||||||
setState((state) => ({
|
|
||||||
importJobQueue: {
|
|
||||||
...state.importJobQueue,
|
|
||||||
successfulJobCount: completedJobCount,
|
|
||||||
mostRecentImport: importResult.data.rawFileDetails.name,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
|
|
||||||
const { failedJobCount } = data;
|
|
||||||
setState((state) => ({
|
|
||||||
importJobQueue: {
|
|
||||||
...state.importJobQueue,
|
|
||||||
failedJobCount,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
socketIOInstance.on("searchResultsAvailable", (data) => {
|
|
||||||
console.log(data);
|
|
||||||
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1b. Clear the localStorage sessionId upon receiving the
|
|
||||||
// LS_IMPORT_QUEUE_DRAINED event
|
|
||||||
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
|
||||||
localStorage.removeItem("sessionId");
|
|
||||||
setState((state) => ({
|
|
||||||
importJobQueue: {
|
|
||||||
...state.importJobQueue,
|
|
||||||
status: "drained",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ComicVine Scraping status
|
|
||||||
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
|
|
||||||
setState((state) => ({
|
|
||||||
comicvine: {
|
|
||||||
...state.comicvine,
|
|
||||||
scrapingStatus: data.message,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user