Compare commits

..

8 Commits

Author SHA1 Message Date
c6f719e78b 💅🏼 Formatting tweaks to tabs 2024-03-30 21:28:48 -04:00
b4d1b678b1 💅🏼 Formatting improvements 2024-03-29 23:26:43 -04:00
aa3192bc1a 🧲 Added visual indicators of torrent progress 2024-03-29 19:35:57 -04:00
56ddfbd16e 🧲 Surfacing torrent progress in UI via scheduled job 2024-03-27 22:23:24 -05:00
173735da45 🧲 Added downloads panel 2024-03-24 17:30:51 -04:00
f4408cd493 🧲 Fixed the auto-population of search box 2024-03-16 18:34:41 -04:00
41a9428729 🧲 Added a torrent download sub-panel 2024-03-07 05:50:49 -06:00
e1da6ddcef 🪢 Wiring up to addTorrent endpoint 2024-02-27 22:15:32 -05:00
55 changed files with 3520 additions and 3559 deletions

View File

@@ -1,36 +1,19 @@
# Use Node.js 22 as the base image FROM node:18.15.0-alpine
FROM node:22-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>" LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Set the working directory inside the container
WORKDIR /threetwo WORKDIR /threetwo
# Copy package.json and yarn.lock to leverage Docker cache COPY package.json ./
COPY package.json yarn.lock ./ COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# Install build dependencies necessary for native modules (for node-sass) # RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
RUN apk --no-cache add \ RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
g++ \ RUN yarn --ignore-engines
make \
python3 \
autoconf \
automake \
libtool \
nasm \
git
# Install node modules
RUN yarn install --ignore-engines
# Explicitly install sass
RUN yarn add -D sass
# Copy the rest of the application files into the container
COPY . . COPY . .
# Expose the application port (default for Vite)
EXPOSE 5173 EXPOSE 5173
# Start the application with yarn ENTRYPOINT [ "npm", "start" ]
ENTRYPOINT ["yarn", "start"]

View File

@@ -18,8 +18,6 @@
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.1",
"@floating-ui/react": "^0.26.12",
"@floating-ui/react-dom": "^2.0.8",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
@@ -29,25 +27,20 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"airdcpp-apisocket": "^2.5.0-beta.2", "airdcpp-apisocket": "^2.5.0-beta.2",
"axios": "^1.8.2", "axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1", "axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0", "axios-rate-limit": "^1.3.0",
"babel-plugin-styled-components": "^2.1.4", "babel-plugin-styled-components": "^2.1.4",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"ellipsize": "^0.5.1", "ellipsize": "^0.5.1",
"express": "^4.20.0", "express": "^4.17.1",
"filename-parser": "^1.0.2", "filename-parser": "^1.0.2",
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"graphql": "^16.0.0",
"graphql-request": "^7.2.0",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"i18next": "^23.11.1",
"i18next-browser-languagedetector": "^7.2.1",
"i18next-http-backend": "^2.5.0",
"immer": "^10.0.3", "immer": "^10.0.3",
"jsdoc": "^3.6.10", "jsdoc": "^3.6.10",
"keen-slider": "^6.8.6", "keen-slider": "^6.8.6",
@@ -55,7 +48,7 @@
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qs": "^6.10.5", "qs": "^6.10.5",
"react": "^18.3.1", "react": "^18.2.0",
"react-collapsible": "^2.9.0", "react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0", "react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.10.0",
@@ -63,20 +56,20 @@
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4", "react-final-form-arrays": "^3.1.4",
"react-i18next": "^14.1.0",
"react-loader-spinner": "^4.0.0", "react-loader-spinner": "^4.0.0",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-router": "^7.5.2", "react-popper": "^2.3.0",
"react-router": "^6.9.0",
"react-router-dom": "^6.9.0", "react-router-dom": "^6.9.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2", "react-select-async-paginate": "^0.7.2",
"react-sliding-pane": "^7.1.0", "react-sliding-pane": "^7.1.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"react-toastify": "^10.0.5", "reapop": "^4.2.1",
"socket.io-client": "^4.3.2", "socket.io-client": "^4.3.2",
"styled-components": "^6.1.0", "styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.4.19", "vite": "^5.0.5",
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34", "websocket": "^1.0.34",
"zustand": "^4.4.6" "zustand": "^4.4.6"
@@ -115,7 +108,7 @@
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0", "eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.13", "eslint-plugin-storybook": "^0.6.13",
"express": "^4.20.0", "express": "^4.17.1",
"install": "^0.13.0", "install": "^0.13.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
@@ -124,7 +117,7 @@
"prettier": "^2.2.1", "prettier": "^2.2.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"rimraf": "^4.1.3", "rimraf": "^4.1.3",
"sass": "^1.77.0", "sass": "^1.69.5",
"storybook": "^7.3.2", "storybook": "^7.3.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",

View File

@@ -1,20 +1,13 @@
import React, { ReactElement, useEffect } from "react"; import React, { ReactElement } 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 "../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 />
<Outlet /> <Outlet />
<ToastContainer stacked hideProgressBar />
</> </>
); );
}; };

View File

@@ -1,11 +1,7 @@
import React, { import React, { useCallback, ReactElement, useEffect, useState } from "react";
useCallback, import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
ReactElement, import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
useEffect, import { RootState, SearchInstance } from "threetwo-ui-typings";
useRef,
useState,
} from "react";
import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils"; import { difference } from "../../shared/utils/object.utils";
@@ -14,315 +10,230 @@ import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
import type { Socket } from "socket.io-client";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
comicObjectId: string; comicObjectId: any;
comicObject: any; comicObject: any;
settings: any; settings: any;
} }
interface AirDCPPConfig {
protocol: string;
hostname: string;
username: string;
password: string;
}
interface SearchResult {
id: string;
name: string;
type: {
id: string;
str: string;
};
size: number;
slots: {
total: number;
free: number;
};
users: {
user: {
nicks: string;
flags: string[];
};
};
dupe?: any;
}
interface SearchInstanceData {
id: number;
owner: string;
expires_in: number;
}
interface SearchInfo {
query: {
pattern: string;
extensions: string[];
file_type: string;
};
}
interface Hub {
hub_url: string;
identity: {
name: string;
};
value: string;
}
interface SearchFormValues {
issueName: string;
}
export const AcquisitionPanel = ( export const AcquisitionPanel = (
props: IAcquisitionPanelProps, props: IAcquisitionPanelProps,
): ReactElement => { ): ReactElement => {
const socketRef = useRef<Socket>(); const {
const queryClient = useQueryClient(); airDCPPSocketInstance,
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); airDCPPClientConfiguration,
airDCPPSessionInformation,
airDCPPDownloadTick,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
})),
);
const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null); interface SearchData {
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]); query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false); hub_urls: string[] | undefined | null;
const [isSearching, setIsSearching] = useState(false); priority: PriorityEnum;
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<SearchInstanceData | null>(null); }
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<SearchInfo | null>(null);
const [searchError, setSearchError] = useState<string | null>(null);
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
const { comicObjectId } = props; const { comicObjectId } = props;
const issueName = props.query.issue.name || ""; const issueName = props.query.issue.name || "";
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Search timeout duration in milliseconds (30 seconds) const [dcppQuery, setDcppQuery] = useState({});
const SEARCH_TIMEOUT_MS = 30000; const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
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++"
useEffect(() => { useEffect(() => {
const socket = useStore.getState().getSocket("manual"); // AirDC++ search query
socketRef.current = socket; const dcppSearchQuery = {
// --- Handlers ---
const handleResultAdded = ({ result }: { result: SearchResult }) => {
setAirDCPPSearchResults((prev) =>
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
);
};
const handleResultUpdated = ({ result }: { result: SearchResult }) => {
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: { 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: { 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);
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: isLoadingSettings,
isError: isSettingsError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
method: "GET",
}),
});
const { data: hubs, isLoading: isLoadingHubs } = useQuery({
queryKey: ["hubs", settings?.data.directConnect?.client?.host],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: 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(() => {
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([]);
setIsSearching(true);
setSearchError(null);
socketRef.current.emit("call", "socket.search", {
query: searchData,
namespace: "/manual",
config: airDCPPConfig,
});
};
const download = async (
searchInstanceId: number,
resultId: string,
comicObjectId: string,
name: string,
size: number,
type: SearchResult["type"],
config: AirDCPPConfig,
): Promise<void> => {
if (!socketRef.current) {
console.error("Socket connection not available");
return;
}
socketRef.current.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => console.log("Download initiated:", data),
);
};
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: { query: {
pattern: `${searchQuery.issueName.trim()}`, pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"], extensions: ["cbz", "cbr", "cb7"],
}, },
hub_urls: [hubs.data[0].hub_url], hub_urls: map(hubs, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, []);
/**
* 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 (data: SearchData, ADCPPSocket: any) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
const instance: SearchInstance = await ADCPPSocket.post("search");
setAirDCPPSearchStatus(true);
// We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
setAirDCPPSearchResults((state) => [...state, groupedResult]);
},
instance.id,
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.result.id === groupedResult.result.id,
);
const updatedState = [...airDCPPSearchResults];
if (
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) {
updatedState[bundleToUpdateIndex] = groupedResult;
}
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
setAirDCPPSearchInstance(instance);
setAirDCPPSearchStatus(false);
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
};
/**
* 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} ADCPPSocket - description
* @returns {void} - description
*/
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
ADCPPSocket: any,
): void => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
);
if (!isNil(downloadResult)) {
bundleDBImportResult = await axios({
method: "POST",
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
},
});
console.log(bundleDBImportResult?.data);
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
// dispatch({
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
// downloadResult,
// bundleDBImportResult,
// });
//
// dispatch({
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
// comicBookDetail: bundleDBImportResult.data,
// IMS_inProgress: false,
// });
}
} catch (error) {
throw error;
}
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (hub) => hub.hub_url),
priority: 5, priority: 5,
}; };
search(manualQuery); search(manualQuery, airDCPPSocketInstance);
}; };
return ( return (
<> <>
<div className="mt-5 mb-3"> <div className="mt-5">
{isLoadingSettings || isLoadingHubs ? ( {!isEmpty(airDCPPSocketInstance) ? (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin" />
Loading configuration...
</div>
) : !isEmpty(hubs?.data) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
@@ -342,31 +253,20 @@ export const AcquisitionPanel = (
{...input} {...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name" placeholder="Type an issue/volume name"
disabled={isSearching}
/> />
<button <button
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed" 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"
type="submit" type="submit"
disabled={isSearching}
> >
<div className="flex flex-row items-center"> <div className="flex flex-row">
{isSearching ? ( Search DC++
<> <div className="h-5 w-5 ml-2">
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" /> <img
Searching... src="/src/client/assets/img/airdcpp_logo.svg"
</> className="h-5 w-5"
) : ( />
<> </div>
Search DC++
<div className="h-5 w-5 ml-2">
<img
src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</>
)}
</div> </div>
</button> </button>
</div> </div>
@@ -378,45 +278,27 @@ export const AcquisitionPanel = (
)} )}
/> />
) : ( ) : (
<article <div className="">
role="alert" <article className="">
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600" <div className="">
> AirDC++ is not configured. Please configure it in{" "}
No AirDC++ hub configured. Please configure it in{" "} <code>Settings &gt; AirDC++ &gt; Connection</code>.
<code>Settings &gt; AirDC++ &gt; Hubs</code>. </div>
</article> </article>
</div>
)} )}
</div> </div>
{/* Search Error Display */}
{searchError && (
<article
role="alert"
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-red-500 bg-red-50 p-4 dark:border-s-4 dark:border-red-600 dark:bg-red-300 dark:text-slate-600"
>
<strong>Error:</strong> {searchError}
</article>
)}
{/* configured hub */}
{!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.data[0].hub_url}
</span>
)}
{/* AirDC++ search instance details */} {/* AirDC++ search instance details */}
{airDCPPSearchInstance && {!isNil(airDCPPSearchInstance) &&
airDCPPSearchInfo && !isEmpty(airDCPPSearchInfo) &&
hubs?.data && ( !isNil(hubs) && (
<div className="flex flex-row gap-3 my-5 font-hasklig"> <div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700"> <div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl> <dl>
<dt> <dt>
<div className="mb-1"> <div className="mb-1">
{hubs.data.map((value: Hub, idx: number) => ( {hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}> <span className="tag is-warning" key={idx}>
{value.identity.name} {value.identity.name}
</span> </span>
@@ -455,119 +337,138 @@ export const AcquisitionPanel = (
)} )}
{/* AirDC++ results */} {/* AirDC++ results */}
<div className=""> <div className="columns">
{airDCPPSearchResults.length > 0 ? ( {!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="overflow-x-auto max-w-full mt-6"> <div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100"> <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
<thead> <thead>
<tr className="border-b border-gray-300 dark:border-slate-700"> <tr>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <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 px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Type Type
</th> </th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Slots Slots
</th> </th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{map( {map(airDCPPSearchResults, ({ result }, idx) => {
airDCPPSearchResults, return (
({ dupe, type, name, id, slots, users, size }, idx) => (
<tr <tr
key={idx} key={idx}
className={ className={
!isNil(dupe) !isNil(result.dupe)
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700" ? "bg-gray-100 dark:bg-gray-700"
: "border-b border-gray-200 dark:border-slate-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" && ( {result.type.id === "directory" ? (
<i className="fas fa-folder mr-1"></i> <i className="fas fa-folder"></i>
)} ) : null}
{ellipsize(name, 45)} {ellipsize(result.name, 70)}
</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(result.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">
{result.users.user.nicks}
</span> </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">
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
{users.user.nicks}
</span> </span>
{users.user.flags.map((flag: string, flagIdx: number) => ( {/* Flags */}
<span {result.users.user.flags.map((flag, idx) => (
key={flagIdx} <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">
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="pr-1 pt-1">
> <i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i> </span>
{flag}
<span className="text-md text-slate-500 dark:text-slate-900">
{flag}
</span>
</span> </span>
))} ))}
</div> </div>
</dd> </dd>
</dl> </dl>
</td> </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>
{/* TYPE */} <span className="text-md text-slate-500 dark:text-slate-900">
<td className="px-2 py-3"> {result.type.str}
<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}
</span> </span>
</td> </td>
<td className="px-2">
{/* Slots */}
<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>
{/* SLOTS */} <span className="text-md text-slate-500 dark:text-slate-900">
<td className="px-2 py-3"> {result.slots.total} slots; {result.slots.free} free
<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>
{slots.total} slots; {slots.free} free
</span> </span>
</td> </td>
<td className="px-2">
{/* ACTIONS */}
<td className="px-2 py-3">
<button <button
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => { onClick={() =>
if (airDCPPSearchInstance && airDCPPConfig) { download(
download( airDCPPSearchInstance.id,
airDCPPSearchInstance.id, result.id,
id, comicObjectId,
comicObjectId, result.name,
name, result.size,
size, result.type,
type, airDCPPSocketInstance,
airDCPPConfig, )
); }
}
}}
disabled={!airDCPPSearchInstance || !airDCPPConfig}
> >
Download <span className="text-xs">Download</span>
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i> <span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span>
</button> </button>
</td> </td>
</tr> </tr>
), );
)} })}
</tbody> </tbody>
</table> </table>
</div> </div>
) : !isSearching ? ( ) : (
<div className=""> <div className="">
<article <article
role="alert" role="alert"
@@ -593,11 +494,6 @@ export const AcquisitionPanel = (
</div> </div>
</article> </article>
</div> </div>
) : (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-6 p-4">
<i className="icon-[solar--refresh-bold-duotone] h-6 w-6 animate-spin" />
Searching...
</div>
)} )}
</div> </div>
</> </>

View File

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

View File

@@ -3,55 +3,44 @@ import prettyBytes from "pretty-bytes";
import dayjs from "dayjs"; import dayjs from "dayjs";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { map } from "lodash"; import { map } from "lodash";
import { DownloadProgressTick } from "./DownloadProgressTick";
export const AirDCPPBundles = (props) => { export const AirDCPPBundles = (props) => {
return ( return (
<div className="overflow-x-auto w-fit mt-6"> <div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100"> <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead> <thead>
<tr className="border-b border-gray-300 dark:border-slate-700"> <tr>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Filename Filename
</th> </th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Size Size
</th> </th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Status Download Time
</th> </th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase"> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID Bundle ID
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-gray-200">
{map(props.data, (bundle, index) => ( {map(props.data, (bundle) => (
<tr <tr key={bundle.id} className="text-sm">
key={bundle.id} <td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
className={ <h5>{ellipsize(bundle.name, 58)}</h5>
Number(index) !== props.data.length - 1 <span className="text-xs">{ellipsize(bundle.target, 88)}</span>
? "border-b border-gray-200 dark:border-slate-700"
: ""
}
>
<td className="px-3 py-2 align-top">
<h5 className="font-medium text-gray-800 dark:text-slate-200">
{ellipsize(bundle.name, 58)}
</h5>
<p className="text-xs text-gray-500 dark:text-slate-400">
{ellipsize(bundle.target, 88)}
</p>
</td> </td>
<td className="px-3 py-2 align-top"> <td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{prettyBytes(bundle.size)} {prettyBytes(bundle.size)}
</td> </td>
<td className="px-3 py-2 align-top"> <td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<DownloadProgressTick bundleId={bundle.id} /> {dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td> </td>
<td className="px-3 py-2 align-top"> <td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium"> <span className="tag is-warning">{bundle.id}</span>
{bundle.id}
</span>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -1,17 +1,11 @@
import React, { ReactElement, useCallback, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import PropTypes from "prop-types";
import { fetchMetronResource } from "../../../actions/metron.actions"; import { fetchMetronResource } from "../../../actions/metron.actions";
import Creatable from "react-select/creatable"; import Creatable from "react-select/creatable";
import { withAsyncPaginate } from "react-select-async-paginate"; import { withAsyncPaginate } from "react-select-async-paginate";
const CreatableAsyncPaginate = withAsyncPaginate(Creatable); const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
interface AsyncSelectPaginateProps { export const AsyncSelectPaginate = (props): ReactElement => {
metronResource: string;
placeholder?: string;
value?: object;
onChange?(...args: unknown[]): unknown;
}
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
const [value, setValue] = useState(null); const [value, setValue] = useState(null);
const [isAddingInProgress, setIsAddingInProgress] = useState(false); const [isAddingInProgress, setIsAddingInProgress] = useState(false);
@@ -44,4 +38,11 @@ export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactEleme
); );
}; };
AsyncSelectPaginate.propTypes = {
metronResource: PropTypes.string.isRequired,
placeholder: PropTypes.string,
value: PropTypes.object,
onChange: PropTypes.func,
};
export default AsyncSelectPaginate; export default AsyncSelectPaginate;

View File

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

View File

@@ -1,21 +1,12 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { isEmpty, isUndefined } from "lodash"; import { isEmpty, isUndefined } from "lodash";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
interface ComicVineDetailsProps { export const ComicVineDetails = (props): ReactElement => {
updatedAt?: string;
data?: {
name?: string;
number?: string;
resource_type?: string;
id?: number;
};
}
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
const { data, updatedAt } = props; const { data, updatedAt } = props;
return ( return (
<div className="text-slate-500 dark:text-gray-400"> <div className="text-slate-500 dark:text-gray-400">
@@ -116,3 +107,13 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
}; };
export default ComicVineDetails; export default ComicVineDetails;
ComicVineDetails.propTypes = {
updatedAt: PropTypes.string,
data: PropTypes.shape({
name: PropTypes.string,
number: PropTypes.string,
resource_type: PropTypes.string,
id: PropTypes.number,
}),
};

View File

@@ -1,129 +1,32 @@
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import React, { ReactElement, useEffect, useRef, useState } from "react"; import React, { ReactElement } from "react";
import { useStore } from "../../store";
import type { Socket } from "socket.io-client";
/**
* @typedef {Object} DownloadProgressTickProps
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
*/
interface DownloadProgressTickProps {
bundleId: string;
}
/**
* Shape of the download tick data received over the socket.
*
* @typedef DownloadTickData
* @property {number} id - Unique download ID
* @property {string} name - File name (e.g. "movie.mkv")
* @property {number} downloaded_bytes - Bytes downloaded so far
* @property {number} size - Total size in bytes
* @property {number} speed - Current download speed (bytes/sec)
* @property {number} seconds_left - Estimated seconds remaining
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
* @property {{ online: number; total: number; str: string }} sources
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
*/
interface DownloadTickData {
id: number;
name: string;
downloaded_bytes: number;
size: number;
speed: number;
seconds_left: number;
status: {
id: string;
str: string;
completed: boolean;
downloaded: boolean;
failed: boolean;
hook_error: any;
};
sources: {
online: number;
total: number;
str: string;
};
target: string;
}
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
bundleId,
}): ReactElement | null => {
const socketRef = useRef<Socket>();
const [tick, setTick] = useState<DownloadTickData | null>(null);
useEffect(() => {
const socket = useStore.getState().getSocket("manual");
socketRef.current = socket;
socket.emit("call", "socket.listenFileProgress", {
namespace: "/manual",
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
});
/**
* Handler for each "downloadTick" event.
* Only update state if event.id matches bundleId.
*
* @param {DownloadTickData} data - Payload from the server
*/
const onDownloadTick = (data: DownloadTickData) => {
// Compare numeric data.id to string bundleId
console.log(data.id);
console.log(`bundleId is ${bundleId}`)
if (data.id === parseInt(bundleId, 10)) {
setTick(data);
}
};
socket.on("downloadTick", onDownloadTick);
return () => {
socket.off("downloadTick", onDownloadTick);
};
}, [socketRef, bundleId]);
if (!tick) {
return <>Nothing detected.</>;
}
// Compute human-readable values and percentages
const downloaded = prettyBytes(tick.downloaded_bytes);
const total = prettyBytes(tick.size);
const percent = tick.size > 0
? Math.round((tick.downloaded_bytes / tick.size) * 100)
: 0;
const speed = prettyBytes(tick.speed) + "/s";
const minutesLeft = Math.round(tick.seconds_left / 60);
export const DownloadProgressTick = (props): ReactElement => {
return ( return (
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm"> <div>
{/* Downloaded vs Total */} <h4 className="is-size-5">{props.data.name}</h4>
<div className="mt-1 flex items-center space-x-2"> <div>
<span className="text-sm text-gray-700">{downloaded} of {total}</span> <span className="is-size-4 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "}
</span>
<progress
className="progress is-small is-success"
value={props.data.downloaded_bytes}
max={props.data.size}
>
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
100}
%
</progress>
</div>
<div className="is-size-6 mt-1 mb-2">
<p>{prettyBytes(props.data.speed)} per second.</p>
Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)}
</div> </div>
{/* Progress bar */} <div>{props.data.target}</div>
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-green-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
{/* Speed and Time Left */}
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
<span>Speed: {speed}</span>
<span>Time left: {minutesLeft} min</span>
</div>
</div> </div>
); );
}; };

View File

@@ -1,5 +1,6 @@
import React, { useEffect, ReactElement, useState, useMemo } from "react"; import React, { useEffect, useContext, ReactElement, useState } from "react";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { RootState } from "threetwo-ui-typings";
import { isEmpty, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles"; import { AirDCPPBundles } from "./AirDCPPBundles";
import { TorrentDownloads } from "./TorrentDownloads"; import { TorrentDownloads } from "./TorrentDownloads";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -13,142 +14,134 @@ import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
export interface TorrentDetails { interface IDownloadsPanelProps {
infoHash: string; key: number;
progress: number;
downloadSpeed?: number;
uploadSpeed?: number;
} }
/** export const DownloadsPanel = (
* DownloadsPanel displays two tabs of download information for a specific comic: props: IDownloadsPanelProps,
* - DC++ (AirDCPP) bundles ): ReactElement | null => {
* - Torrent downloads
* It also listens for real-time torrent updates via a WebSocket.
*
* @component
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
*/
export const DownloadsPanel = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [bundles, setBundles] = useState([]);
const [infoHashes, setInfoHashes] = useState<string[]>([]); const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]); const [torrentDetails, setTorrentDetails] = useState([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">( const [activeTab, setActiveTab] = useState("torrents");
"directconnect", const { airDCPPSocketInstance, socketIOInstance } = useStore(
useShallow((state: any) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
socketIOInstance: state.socketIOInstance,
})),
); );
const { socketIOInstance } = useStore( // React to torrent progress data sent over websockets
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })), socketIOInstance.on("AS_TORRENT_DATA", (data) => {
); const torrents = data.torrents
.flatMap(({ _id, details }) => {
/** if (_id === comicObjectId) {
* Registers socket listeners on mount and cleans up on unmount. return details;
*/
useEffect(() => {
if (!socketIOInstance) return;
/**
* Handler for incoming torrent data events.
* Merges new entries or updates existing ones by infoHash.
*
* @param {TorrentDetails} data - Payload from the socket event.
*/
const handleTorrentData = (data: TorrentDetails) => {
setTorrentDetails((prev) => {
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
if (idx === -1) {
return [...prev, data];
} }
const next = [...prev]; })
next[idx] = { ...next[idx], ...data }; .filter((item) => item !== undefined);
return next; setTorrentDetails(torrents);
}); });
}; // Fetch the downloaded files and currently-downloading file(s) from AirDC++
const { data: comicObject, isSuccess } = useQuery({
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData); queryKey: ["bundles"],
return () => {
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
};
}, [socketIOInstance]);
// ————— DC++ Bundles (via REST) —————
const { data: bundles } = useQuery({
queryKey: ["bundles", comicObjectId],
queryFn: async () => queryFn: async () =>
await axios({ await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`, url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST", method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: { data: {
comicObjectId, id: `${comicObjectId}`,
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
}, },
}), }),
}); });
// ————— Torrent Jobs (via REST) ————— const getBundles = async (comicObject) => {
const { data: rawJobs = [] } = useQuery<any[]>({ if (comicObject?.data.acquisition.directconnect) {
queryKey: ["torrents", comicObjectId], const filteredBundles =
queryFn: async () => { comicObject.data.acquisition.directconnect.downloads.map(
const { data } = await axios.get( async ({ bundleId }) => {
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`, return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
{ params: { trigger: activeTab } }, },
); );
return Array.isArray(data) ? data : []; return await Promise.all(filteredBundles);
}, }
initialData: [], };
enabled: activeTab === "torrents",
// Call the scheduled job for fetching torrent data
// triggered by the active tab been set to "torrents"
const { data: torrentData } = useQuery({
queryFn: () =>
axios({
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
method: "GET",
params: {
trigger: activeTab,
},
}),
queryKey: [activeTab],
}); });
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
useEffect(() => { useEffect(() => {
if (activeTab !== "torrents") return; getBundles(comicObject).then((result) => {
setInfoHashes(rawJobs.map((j: any) => j.infoHash)); setBundles(result);
}, [activeTab]); });
}, [comicObject]);
return ( return (
<> <div className="columns is-multiline">
<div className="mt-5 mb-3"> {!isEmpty(airDCPPSocketInstance) &&
<nav className="flex space-x-2"> !isEmpty(bundles) &&
<button activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
onClick={() => setActiveTab("directconnect")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "directconnect"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
DC++
</button>
<button
onClick={() => setActiveTab("torrents")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "torrents"
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
Torrents
</button>
</nav>
<div className="mt-4"> <div>
{activeTab === "torrents" ? ( <div className="sm:hidden">
<TorrentDownloads data={torrentDetails} /> <label htmlFor="Download Type" className="sr-only">
) : !isNil(bundles?.data) && bundles.data.length > 0 ? ( Download Type
<AirDCPPBundles data={bundles.data} /> </label>
) : (
<p>No DC++ bundles found.</p> <select id="Tab" className="w-full rounded-md border-gray-200">
)} <option>DC++ Downloads</option>
<option>Torrents</option>
</select>
</div>
<div className="hidden sm:block">
<nav className="flex gap-6" aria-label="Tabs">
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "directconnect"
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
aria-current="page"
onClick={() => setActiveTab("directconnect")}
>
DC++ Downloads
</a>
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "torrents"
? "bg-slate-200 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
onClick={() => setActiveTab("torrents")}
>
Torrents
</a>
</nav>
</div> </div>
</div> </div>
</>
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
</div>
); );
}; };
export default DownloadsPanel; export default DownloadsPanel;

View File

@@ -1,157 +1,127 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
interface RawFileDetailsProps { export const RawFileDetails = (props): ReactElement => {
data?: {
rawFileDetails?: {
containedIn?: string;
name?: string;
fileSize?: number;
path?: string;
extension?: string;
mimeType?: string;
cover?: {
filePath?: string;
};
};
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
};
children?: any;
}
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } = const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data || {}; props.data;
if (!rawFileDetails) return null;
return ( return (
<> <>
<div className="max-w-2xl ml-5"> <div className="max-w-2xl ml-5">
{/* Title */} <div className="px-4 sm:px-6">
<div className="px-4 sm:px-6 mb-6">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
<span className="text-xl font-semibold">{rawFileDetails?.name}</span> <span className="text-xl">{rawFileDetails.name}</span>
</p> </p>
</div> </div>
<div className="px-4 py-5 sm:px-6">
{/* File Binary Details Section */} <dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700"> <div className="sm:col-span-1">
<div className="mb-4"> <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1"> Raw File Details
<i className="icon-[solar--document-bold-duotone] w-5 h-5"></i> </dt>
File Binary Details <dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
</h3> {rawFileDetails.containedIn +
</div> "/" +
<div className="pl-6"> rawFileDetails.name +
<dl className="space-y-4"> rawFileDetails.extension}
<div> </dd>
<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>
{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>
{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>
<div>{props.children}</div> <div className="sm:col-span-1">
</div> <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">
<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">
<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>
</div>
</div> </div>
</> </>
); );
}; };
export default RawFileDetails; export default RawFileDetails;
RawFileDetails.propTypes = {
data: PropTypes.shape({
rawFileDetails: PropTypes.shape({
containedIn: PropTypes.string,
name: PropTypes.string,
fileSize: PropTypes.number,
path: PropTypes.string,
extension: PropTypes.string,
mimeType: PropTypes.string,
cover: PropTypes.shape({
filePath: PropTypes.string,
}),
}),
inferredMetadata: PropTypes.shape({
issue: PropTypes.shape({
year: PropTypes.string,
name: PropTypes.string,
number: PropTypes.number,
subtitle: PropTypes.string,
}),
}),
created_at: PropTypes.string,
updated_at: PropTypes.string,
}),
children: PropTypes.any,
};

View File

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

View File

@@ -23,17 +23,15 @@ export const TorrentSearchPanel = (props) => {
url: `${PROWLARR_SERVICE_BASE_URI}/search`, url: `${PROWLARR_SERVICE_BASE_URI}/search`,
method: "POST", method: "POST",
data: { data: {
prowlarrQuery: { port: "9696",
port: "9696", apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
apiKey: "38c2656e8f5d4790962037b8c4416a8f", offset: 0,
offset: 0, categories: [7030],
categories: [7030], query: searchTerm.issueName,
query: searchTerm.issueName, host: "localhost",
host: "localhost", limit: 100,
limit: 100, type: "search",
type: "search", indexerIds: [2],
indexerIds: [2],
},
}, },
}); });
}, },

View File

@@ -21,15 +21,13 @@ export const Dashboard = (): ReactElement => {
limit: 5, limit: 5,
sort: { updatedAt: "-1" }, sort: { updatedAt: "-1" },
}, },
predicate: { predicate: { "acquisition.source.wanted": false },
wanted: { $exists: false },
},
comicStatus: "recent", comicStatus: "recent",
}, },
}), }),
queryKey: ["recentComics"], queryKey: ["recentComics"],
}); });
// Wanted Comics
const { data: wantedComics } = useQuery({ const { data: wantedComics } = useQuery({
queryFn: async () => queryFn: async () =>
await axios({ await axios({
@@ -41,9 +39,7 @@ export const Dashboard = (): ReactElement => {
limit: 5, limit: 5,
sort: { updatedAt: "-1" }, sort: { updatedAt: "-1" },
}, },
predicate: { predicate: { "acquisition.source.wanted": true },
wanted: { $exists: true, $ne: null },
},
}, },
}), }),
queryKey: ["wantedComics"], queryKey: ["wantedComics"],

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useState, useEffect } from "react"; import React, { ReactElement, useState } from "react";
import { map } from "lodash"; import { map } from "lodash";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import Header from "../shared/Header"; import Header from "../shared/Header";
@@ -31,38 +31,27 @@ export const PullList = (): ReactElement => {
// datepicker // datepicker
const date = new Date(); const date = new Date();
const [inputValue, setInputValue] = useState<string>( const [inputValue, setInputValue] = useState<string>(
format(date, "yyyy/M/dd"), format(date, "M-dd-yyyy"),
); );
// Responsive slides per view
const [slidesPerView, setSlidesPerView] = useState(1);
// keen slider // keen slider
const [sliderRef, instanceRef] = useKeenSlider({ const [sliderRef, instanceRef] = useKeenSlider(
loop: true, {
mode: "free-snap", loop: true,
slides: { slides: {
perView: slidesPerView, origin: "auto",
spacing: 15, number: 15,
perView: 5,
spacing: 15,
},
slideChanged() {
console.log("slide changed");
},
}, },
slideChanged() { [
console.log("slide changed"); // add plugins here
}, ],
}); );
// Update slider when slidesPerView changes
useEffect(() => {
if (instanceRef.current) {
instanceRef.current.update({
slides: {
perView: slidesPerView,
spacing: 15,
},
});
}
}, [slidesPerView, instanceRef]);
const { const {
data: pullList, data: pullList,
@@ -91,81 +80,79 @@ export const PullList = (): ReactElement => {
return ( return (
<> <>
<div className="content"> <div className="content">
<div className="mx-auto"> <Header
<Header headerContent="Discover"
headerContent="Discover" subHeaderContent={
subHeaderContent={ <span className="text-md">
<span className="text-md"> Pull List aggregated for the week from{" "}
Pull List aggregated for the week from{" "} <span className="underline">
<span className="underline"> <a href="https://leagueofcomicgeeks.com/comics/new-comics">
<a href="https://leagueofcomicgeeks.com"> League Of Comic Geeks
League Of Comic Geeks </a>
</a> <i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span> </span>
} </span>
iconClassNames="fa-solid fa-binoculars mr-2" }
link="/pull-list/all/" 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-5 mb-3">
<div className="flex flex-row gap-4 my-3"> {/* select week */}
<Form <div className="flex flex-row gap-4 my-3">
onSubmit={() => {}} <Form
render={({ handleSubmit }) => ( onSubmit={() => {}}
<form> render={({ handleSubmit }) => (
<div className="flex flex-col gap-2"> <form>
{/* week selection for pull list */} <div className="flex flex-col gap-2">
<DatePickerDialog {/* week selection for pull list */}
inputValue={inputValue} <DatePickerDialog
setter={setInputValue} inputValue={inputValue}
/> setter={setInputValue}
{inputValue && ( />
<div className="text-sm"> {inputValue && (
Showing pull list for{" "} <div className="text-sm">
<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"> Showing pull list for{" "}
{inputValue} <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">
</span> {inputValue}
</div> </span>
)} </div>
</div> )}
</form> </div>
)} </form>
/> )}
</div> />
</div> </div>
</div> </div>
</div> </div>
{isSuccess && !isLoading && ( {isSuccess && !isLoading && (
<div ref={sliderRef} className="keen-slider"> <div ref={sliderRef} className="keen-slider flex flex-row">
{map(pullList?.data.result, (issue, idx) => { {map(pullList?.data.result, (issue, idx) => {
return ( return (
<div key={idx} className="keen-slider__slide"> <div key={idx} className="keen-slider__slide">
<Card <Card
orientation={"vertical-2"} orientation={"vertical-2"}
imageUrl={issue.coverImageUrl} imageUrl={issue.cover}
hasDetails hasDetails
title={ellipsize(issue.issueName, 25)} title={ellipsize(issue.name, 25)}
> >
<div className="px-1"> <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"> <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} {issue.publisher}
</span> </span>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<button <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" 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)} onClick={() => addToLibrary("locg", issue)}
> >
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "} <i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want Want
</button> </button>
</div>
</div> </div>
</div> </Card>
</Card> </div>
</div> );
);
})} })}
</div> </div>
)} )}

View File

@@ -18,16 +18,14 @@ type RecentlyImportedProps = {
export const RecentlyImported = ( export const RecentlyImported = (
comics: RecentlyImportedProps, comics: RecentlyImportedProps,
): ReactElement => { ): ReactElement => {
console.log(comics);
return ( return (
<div> <div>
<div className="mx-auto" style={{ maxWidth: '1400px' }}> <Header
<Header headerContent="Recently Imported"
headerContent="Recently Imported" subHeaderContent="Recent Library activity such as imports, tagging, etc."
subHeaderContent="Recent Library activity such as imports, tagging, etc." iconClassNames="fa-solid fa-binoculars mr-2"
iconClassNames="fa-solid fa-binoculars mr-2" />
/> <div className="grid grid-cols-5 gap-6 mt-3">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 gap-6 mt-3">
{comics?.comics.map( {comics?.comics.map(
( (
{ {
@@ -35,7 +33,9 @@ export const RecentlyImported = (
rawFileDetails, rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg }, sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata, inferredMetadata,
wanted: { source } = {}, acquisition: {
source: { name },
},
}, },
idx, idx,
) => { ) => {
@@ -45,14 +45,11 @@ export const RecentlyImported = (
comicInfo, comicInfo,
locg, locg,
}); });
const { issue, coverURL, icon } = determineExternalMetadata( const { issue, coverURL, icon } = determineExternalMetadata(name, {
source, comicvine,
{ comicInfo,
comicvine, locg,
comicInfo, });
locg,
},
);
const isComicVineMetadataAvailable = const isComicVineMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
@@ -126,7 +123,6 @@ export const RecentlyImported = (
); );
}, },
)} )}
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -152,10 +152,10 @@ export const Library = (): ReactElement => {
accessorKey: "_source.createdAt", accessorKey: "_source.createdAt",
cell: (info) => { cell: (info) => {
return !isNil(info.getValue()) ? ( return !isNil(info.getValue()) ? (
<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"> <div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
<i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" /> <p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
{format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")} {format(parseISO(info.getValue()), "h aaaa")}
</span> </div>
) : null; ) : null;
}, },
}, },
@@ -164,25 +164,23 @@ export const Library = (): ReactElement => {
accessorKey: "_source.acquisition", accessorKey: "_source.acquisition",
cell: (info) => ( cell: (info) => (
<div className="flex flex-col gap-2 ml-3 my-3"> <div className="flex flex-col gap-2 ml-3 my-3">
{/* DC++ Downloads */} <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">
{info.getValue().directconnect?.downloads?.length > 0 ? ( <span className="pr-1 pt-1">
<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-5 h-5"></i>
<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>
) : null} <span className="text-md text-slate-900 dark:text-slate-900">
DC++: {info.getValue().directconnect.downloads.length}
</span>
</span>
{/* Torrent Downloads */} <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">
{info.getValue().torrent.length > 0 ? ( <span className="pr-1 pt-1">
<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-5 h-5"></i>
<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>
) : null} <span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.length}
</span>
</span>
</div> </div>
), ),
}, },

View File

@@ -1,16 +1,13 @@
import React, { ReactElement, useState } from "react"; import React, { useCallback, ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash"; import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings"; import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util";
import PopoverButton from "../shared/PopoverButton";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMutation } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
COMICVINE_SERVICE_URI, COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI, LIBRARY_SERVICE_BASE_URI,
@@ -23,121 +20,64 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [comicVineMetadata, setComicVineMetadata] = useState({}); const [comicVineMetadata, setComicVineMetadata] = useState({});
const [selectedResource, setSelectedResource] = useState("volume"); const getCVSearchResults = (searchQuery) => {
const { t } = useTranslation(); setSearchQuery(searchQuery.search);
const handleResourceChange = (value) => {
setSelectedResource(value);
}; };
const { const {
mutate,
data: comicVineSearchResults, data: comicVineSearchResults,
isPending, isLoading,
isSuccess, isSuccess,
} = useMutation({ } = useQuery({
mutationFn: async (data: { search: string; resource: string }) => { queryFn: async () =>
const { search, resource } = data; await axios({
return await axios({
url: `${COMICVINE_SERVICE_URI}/search`, url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET", method: "GET",
params: { params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69", api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: search, query: searchQuery,
format: "json", format: "json",
limit: "10", limit: "10",
offset: "0", offset: "0",
field_list: field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number", "id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: resource, resources: "issue",
}, },
}); }),
}, queryKey: ["comicvineSearchResults", searchQuery],
enabled: !isNil(searchQuery),
}); });
// add to library // add to library
const { data: additionResult, mutate: addToWantedList } = useMutation({ const { data: additionResult } = useQuery({
mutationFn: async ({ queryFn: async () =>
source, await axios({
comicObject,
markEntireVolumeWanted,
resourceType,
}) => {
let volumeInformation = {};
let issues = [];
switch (resourceType) {
case "issue":
const { id, api_detail_url, image, cover_date, issue_number } =
comicObject;
// Add issue metadata
issues.push({
id,
url: api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
console.log(issues);
// Get volume metadata from CV
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
method: "POST",
data: {
volumeURI: comicObject.volume.api_detail_url,
fieldList:
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
},
});
// set volume metadata key
volumeInformation = response.data?.results;
break;
case "volume":
const {
id: volumeId,
api_detail_url: apiUrl,
image: volumeImage,
name,
publisher,
} = comicObject;
volumeInformation = {
id: volumeId,
url: apiUrl,
image: volumeImage,
name,
publisher,
};
break;
default:
console.log("Invalid resource type.");
break;
}
// Add to wanted list
return await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`, url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST", method: "POST",
data: { data: {
importType: "new", importType: "new",
payload: { payload: {
rawFileDetails: {
name: "",
},
importStatus: { importStatus: {
isImported: false, // wanted, but not acquired yet. isImported: true,
tagged: false, tagged: false,
matchedResult: { matchedResult: {
score: "0", score: "0",
}, },
}, },
wanted: { sourcedMetadata:
source, { comicvine: comicVineMetadata?.comicData } || null,
markEntireVolumeWanted, acquisition: { source: { wanted: true, name: "comicvine" } },
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
}, },
}, },
}); }),
}, queryKey: ["additionResult"],
enabled: !isNil(comicVineMetadata.comicData),
}); });
const addToLibrary = (sourceName: string, comicData) => const addToLibrary = (sourceName: string, comicData) =>
@@ -147,15 +87,6 @@ export const Search = ({}: ISearchProps): ReactElement => {
return { __html: html }; return { __html: html };
}; };
const onSubmit = async (values) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
return ( return (
<div> <div>
<section> <section>
@@ -176,7 +107,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
</header> </header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form <Form
onSubmit={onSubmit} onSubmit={getCVSearchResults}
initialValues={{ initialValues={{
...formData, ...formData,
}} }}
@@ -208,254 +139,78 @@ export const Search = ({}: ISearchProps): ReactElement => {
Search Search
</button> </button>
</div> </div>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form> </form>
)} )}
/> />
</div> </div>
{isPending && ( {isLoading && <>Loading kaka...</>}
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> {!isNil(comicVineSearchResults?.data.results) &&
Loading results... !isEmpty(comicVineSearchResults?.data.results) ? (
</div> <div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto w-full sm:w-[90vw] md:w-[80vw] lg:w-[70vw] max-w-6xl px-4 py-6">
{comicVineSearchResults.data.results.map((result) => { {comicVineSearchResults.data.results.map((result) => {
return result.resource_type === "issue" ? ( return isSuccess ? (
<div <div key={result.id} className="mb-5">
key={result.id} <div className="flex flex-row">
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="mr-5">
> <Card
{/* IMAGE */} key={result.id}
<div className="flex-shrink-0"> orientation={"cover-only"}
<Card imageUrl={result.image.small_url}
orientation="cover-only" hasDetails={false}
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>
<div className="column">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
{/* SUBMETA */} <div className="control">
<div className="flex flex-wrap gap-2 mt-2"> <div className="tags has-addons">
{/* Cover Date Token */} <span className="tag is-warning">{result.id}</span>
{result.cover_date && ( </div>
<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"> </div>
<span className="pr-1 pt-1"> </div>
<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>
)}
{/* ID Token */} <a href={result.api_detail_url}>
<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"> {result.api_detail_url}
<span className="pr-1 pt-1"> </a>
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4"></i> <p>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{result.id}
</span>
</span>
</div>
{/* 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( {ellipsize(
convert(result.description ?? "", { convert(result.description, {
baseElements: { selectors: ["p", "div"] }, baseElements: {
selectors: ["p", "div"],
},
}), }),
300, 320,
)} )}
</p> </p>
)} <div className="mt-2">
<button
{/* CTA BUTTON */} className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
{result.volume.name && ( onClick={() => addToLibrary("comicvine", result)}
<div className="absolute bottom-4 right-4"> >
<PopoverButton <i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
content={`This will add ${result?.volume?.name} to your wanted list.`} Mark as Wanted
clickHandler={() => </button>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div> </div>
)} </div>
</div> </div>
</div> </div>
) : ( ) : (
result.resource_type === "volume" && ( <div>Loading</div>
<div
key={result.id}
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"
>
{/* 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>
{/* 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>
)}
{/* FORMAT DETECTED */}
{result.description &&
!isEmpty(detectIssueTypes(result.description)) && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-4 h-4" />
</span>
<span>
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
)}
{/* 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>
{/* LINK */}
<a
href={result.api_detail_url}
className="text-sm text-blue-500 underline mt-2 break-all"
>
{result.api_detail_url}
</a>
{/* 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"] },
}),
320,
)}
</p>
)}
{result.name ? (
<div className="mt-4 justify-self-end">
<PopoverButton
content={`This will add ${result.count_of_issues} issues your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
) : null}
</div>
</div>
)
); );
})} })}
</div> </div>

View File

@@ -1,20 +1,27 @@
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select"; import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../../store";
import axios from "axios"; import axios from "axios";
import { produce } from "immer";
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
export const AirDCPPHubsForm = (): ReactElement => { export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
} = useStore((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
}));
const { const {
data: settings, data: settings,
isLoading, isLoading,
isError, isError,
refetch,
} = useQuery({ } = useQuery({
queryKey: ["settings"], queryKey: ["settings"],
queryFn: async () => queryFn: async () =>
@@ -22,31 +29,23 @@ export const AirDCPPHubsForm = (): ReactElement => {
url: "http://localhost:3000/api/settings/getAllSettings", url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET", method: "GET",
}), }),
staleTime: Infinity,
}); });
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({ const { data: hubs } = useQuery({
queryKey: ["hubs"], queryKey: ["hubs"],
queryFn: async () => queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
}); });
let hubList = {};
let hubList: any[] = [];
if (!isNil(hubs)) { if (!isNil(hubs)) {
hubList = hubs?.data.map(({ hub_url, identity }) => ({ hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url, value: hub_url,
label: identity.name, label: identity.name,
})); }));
} }
const { mutate } = useMutation({
const mutation = useMutation({
mutationFn: async (values) => mutationFn: async (values) =>
await axios({ await axios({
url: `http://localhost:3000/api/settings/saveSettings`, url: `http://localhost:3000/api/settings/saveSettings`,
@@ -57,112 +56,79 @@ export const AirDCPPHubsForm = (): ReactElement => {
settingsKey: "directConnect", settingsKey: "directConnect",
}, },
}), }),
onSuccess: (data) => { onSuccess: () => {
queryClient.setQueryData(["settings"], (oldData: any) => queryClient.invalidateQueries({ queryKey: ["settings"] });
produce(oldData, (draft: any) => {
draft.data.directConnect.client = {
...draft.data.directConnect.client,
...data.data.directConnect.client,
};
}),
);
}, },
}); });
const validate = async () => {};
const validate = async (values) => {
const errors = {};
// Add any validation logic here if needed
return errors;
};
const SelectAdapter = ({ input, ...rest }) => { const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />; return <Select {...input} {...rest} isClearable isMulti />;
}; };
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error loading settings.</div>;
}
return ( return (
<> <>
{!isEmpty(hubList) && !isUndefined(hubs) ? ( {!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form <Form
onSubmit={(values) => { onSubmit={mutate}
mutation.mutate(values);
}}
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} className="mt-10"> <form onSubmit={handleSubmit}>
<h2 className="text-xl">Configure DC++ Hubs</h2> <div>
<article <h3 className="title">Hubs</h3>
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<h6 className="subtitle has-text-grey-light"> <h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against. Your Select the hubs you want to perform searches against.
selection in the dropdown <strong>will replace</strong> the
existing selection.
</h6> </h6>
</article>
<div className="field">
<label className="block py-1 mt-3">AirDC++ Host</label>
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div> </div>
<button <div className="field">
type="submit" <label className="label">AirDC++ Host</label>
className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" <div className="control">
> <Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
</div>
<button type="submit" className="button is-primary">
Submit Submit
</button> </button>
</form> </form>
)} )}
/> />
) : ( ) : (
<article <>
role="alert" <article className="message">
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600" <div className="message-body">
> No configured hubs detected in AirDC++. <br />
<div className="message-body"> Configure to a hub in AirDC++ and then select a default hub here.
No configured hubs detected in AirDC++. <br /> </div>
Configure to a hub in AirDC++ and then select a default hub here. </article>
</div> </>
</article>
)} )}
{!isEmpty(settings?.data.directConnect?.client.hubs) ? ( {!isEmpty(settings?.data.directConnect?.client.hubs) ? (
<> <>
<div className="mt-4"> <div className="mt-4">
<article className="message is-warning"> <article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary"></div> <div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article> </article>
</div> </div>
<div> <div className="box mt-3">
<span className="flex items-center mt-10 mb-4"> <h6>Default Hub For Searches:</h6>
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5"> {settings?.data.directConnect?.client.hubs.map(
Default Hub for Searches ({ value, label }) => (
</span> <div key={value}>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span> <div>{label}</div>
</span> <span className="is-size-7">{value}</span>
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700"> </div>
{settings?.data.directConnect?.client.hubs.map( ),
({ value, label }) => ( )}
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</div>
</div> </div>
</> </>
) : null} ) : null}

View File

@@ -1,65 +1,76 @@
import React, { useState, useEffect } from "react"; import React, { ReactElement, useCallback } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation"; import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { isUndefined, isEmpty } from "lodash";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm"; import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { initializeAirDCPPSocket, useStore } from "../../../store/index";
import { useShallow } from "zustand/react/shallow";
import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import {
AIRDCPP_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
} from "../../../constants/endpoints";
export const AirDCPPSettingsForm = () => { export const AirDCPPSettingsForm = (): ReactElement => {
const [airDCPPSessionInformation, setAirDCPPSessionInformation] = // cherry-picking selectors for:
useState(null); // 1. initial values for the form
// Fetching all settings // 2. If initial values are present, get the socket information to display
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({ const { setState } = useStore;
queryKey: ["airDCPPSettings"], const {
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`), airDCPPSocketConnected,
}); airDCPPDisconnectionInfo,
airDCPPSessionInformation,
// Fetch session information airDCPPClientConfiguration,
const fetchSessionInfo = (host) => { airDCPPSocketInstance,
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host }); setAirDCPPSocketInstance,
}; } = useStore(
useShallow((state) => ({
// Use effect to trigger side effects on settings fetch success airDCPPSocketConnected: state.airDCPPSocketConnected,
useEffect(() => { airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) { airDCPPClientConfiguration: state.airDCPPClientConfiguration,
const host = settingsData.data.directConnect.client.host; airDCPPSessionInformation: state.airDCPPSessionInformation,
fetchSessionInfo(host).then((response) => { airDCPPSocketInstance: state.airDCPPSocketInstance,
setAirDCPPSessionInformation(response.data); setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
}); })),
}
}, [settingsSuccess, settingsData]);
// Handle setting update and subsequent AirDC++ initialization
const { mutate } = useMutation({
mutationFn: (values) => {
console.log(values);
return axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: values,
settingsKey: "directConnect",
});
},
onSuccess: async (response) => {
const host = response?.data?.directConnect?.client?.host;
if (host) {
const response = await fetchSessionInfo(host);
setAirDCPPSessionInformation(response.data);
// setState({ airDCPPClientConfiguration: host });
}
},
});
const deleteSettingsMutation = useMutation(() =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
); );
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {}; /**
* Mutation to update settings and subsequently initialize
* AirDC++ socket with those settings
*/
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "directConnect" },
}),
onSuccess: async (values) => {
const {
data: {
directConnect: {
client: { host },
},
},
} = values;
const dcppSocketInstance = await initializeAirDCPPSocket(host);
setState({
airDCPPClientConfiguration: host,
airDCPPSocketInstance: dcppSocketInstance,
});
},
});
const deleteSettingsMutation = useMutation(
async () =>
await axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
// const removeSettings = useCallback(async () => {
// // airDCPPSettings.setSettings({});
// }, []);
//
const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration
: {};
return ( return (
<> <>
<ConnectionForm <ConnectionForm
@@ -68,12 +79,13 @@ export const AirDCPPSettingsForm = () => {
formHeading={"Configure AirDC++"} formHeading={"Configure AirDC++"}
/> />
{airDCPPSessionInformation && ( {!isEmpty(airDCPPSessionInformation) ? (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} /> <AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
)} ) : null}
{settingsData?.data && ( {!isEmpty(airDCPPClientConfiguration) ? (
<p className="control mt-4"> <p className="control mt-4">
as
<button <button
className="button is-danger" className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()} onClick={() => deleteSettingsMutation.mutate()}
@@ -81,7 +93,7 @@ export const AirDCPPSettingsForm = () => {
Delete Delete
</button> </button>
</p> </p>
)} ) : null}
</> </>
); );
}; };

View File

@@ -1,38 +0,0 @@
import React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export const DockerVars = (): React.ReactElement => {
const [environmentVariables, setEnvironmentVariables] = React.useState<
Record<string, string>
>({});
const { data } = useQuery({
queryKey: ["docker-vars"],
queryFn: async () => {
await axios({
method: "GET",
url: "http://localhost:3000/api/settings/getEnvironmentVariables",
}).then((response) => {
setEnvironmentVariables(response.data);
});
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
console.log("Docker Vars: ", environmentVariables);
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
<p className="text-gray-600 dark:text-gray-400">
<pre>
{Object.entries(environmentVariables).length > 0
? JSON.stringify(environmentVariables, null, 2)
: "No environment variables found."}
</pre>
</p>
{/* Add your form or content for Docker environment variables here */}
</div>
);
};
export default DockerVars;

View File

@@ -4,7 +4,6 @@ import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm"; import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm"; import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm"; import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
import DockerVars from "./DockerVars/DockerVars";
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses"; import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
import settingsObject from "../../constants/settings/settingsMenu.json"; import settingsObject from "../../constants/settings/settingsMenu.json";
import { isUndefined, map } from "lodash"; import { isUndefined, map } from "lodash";
@@ -13,130 +12,139 @@ interface ISettingsProps {}
export const Settings = (props: ISettingsProps): ReactElement => { export const Settings = (props: ISettingsProps): ReactElement => {
const [active, setActive] = useState("gen-db"); const [active, setActive] = useState("gen-db");
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); console.log(active);
const toggleExpanded = (id: string) => {
setExpanded((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const settingsContent = [ const settingsContent = [
{ id: "adc-hubs", content: <AirDCPPHubsForm /> }, {
{ id: "adc-connection", content: <AirDCPPSettingsForm /> }, id: "adc-hubs",
{id: "gen-docker-vars", content: <DockerVars />}, content: (
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> }, <div key="adc-hubs">
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> }, <AirDCPPHubsForm />
{ id: "core-service", content: <>a</> }, </div>
{ id: "flushdb", content: <SystemSettingsForm /> }, ),
},
{
id: "adc-connection",
content: (
<div key="adc-connection">
<AirDCPPSettingsForm />
</div>
),
},
{
id: "qbt-connection",
content: (
<div key="qbt-connection">
<QbittorrentConnectionForm />
</div>
),
},
{
id: "prwlr-connection",
content: (
<>
<ProwlarrSettingsForm />
</>
),
},
{
id: "core-service",
content: <>a</>,
},
{
id: "flushdb",
content: (
<div key="flushdb">
<SystemSettingsForm />
</div>
),
},
]; ];
return ( return (
<div> <div>
<section> <section>
{/* Header */}
<header className="bg-slate-200 dark:bg-slate-500"> <header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-6 sm:px-6 lg:px-8"> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between"> <div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Settings Settings
</h1> </h1>
<p className="mt-1 text-sm text-gray-500 dark:text-white">
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library. Import comics into the ThreeTwo library.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
<div className="flex flex-row">
{/* Main Layout */} <div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
<div className="flex gap-8 px-12 py-6"> <aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{/* Sidebar */} {map(settingsObject, (settingObject, idx) => {
<div className="relative z-30"> return (
<aside
className="sticky top-6 w-72 max-h-[90vh]
rounded-2xl shadow-xl backdrop-blur-md
bg-white/70 dark:bg-slate-800/60
border border-slate-200 dark:border-slate-700
overflow-hidden"
>
<div className="px-4 py-6 overflow-y-auto">
{map(settingsObject, (settingObject, idx) => (
<div <div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx} key={idx}
className="mb-6 text-slate-700 dark:text-slate-300"
> >
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3"> <h3 className="text-l pb-2">
{settingObject.category.toUpperCase()} {settingObject.category.toUpperCase()}
</h3> </h3>
{/* First level children */}
{!isUndefined(settingObject.children) && ( {!isUndefined(settingObject.children) ? (
<ul> <ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => { {map(settingObject.children, (item, idx) => {
const isOpen = expanded[item.id];
return ( return (
<li key={idx} className="mb-1"> <li key={idx} className="mb-2">
<div <a
onClick={() => toggleExpanded(item.id)} className={
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${ item.id.toString() === active
item.id === active ? "is-active flex items-center"
? "font-semibold text-blue-600 dark:text-blue-400" : "flex items-center"
: "" }
}`} onClick={() => setActive(item.id.toString())}
> >
<span {item.displayName}
onClick={() => setActive(item.id.toString())} </a>
className="flex-1" {/* Second level children */}
> {!isUndefined(item.children) ? (
{item.displayName} <ul className="pl-4 mt-2">
</span> {map(item.children, (item, idx) => (
{!isUndefined(item.children) && ( <li key={item.id} className="mb-2">
<span className="text-xs opacity-60">
{isOpen ? "" : "+"}
</span>
)}
</div>
{!isUndefined(item.children) && isOpen && (
<ul className="pl-4 mt-1">
{map(item.children, (subItem) => (
<li key={subItem.id} className="mb-1">
<a <a
onClick={() => className={
setActive(subItem.id.toString()) item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
} }
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
subItem.id.toString() === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
> >
{subItem.displayName} {item.displayName}
</a> </a>
</li> </li>
))} ))}
</ul> </ul>
)} ) : null}
</li> </li>
); );
})} })}
</ul> </ul>
)} ) : null}
</div> </div>
))} );
</div> })}
</aside> </aside>
</div> </div>
{/* Content */} {/* content for settings */}
<main className="flex-1 px-2 py-2"> <div className="flex mx-12">
{settingsContent.map(({ id, content }) => <div className="">
active === id ? <div key={id}>{content}</div> : null, {map(settingsContent, ({ id, content }) =>
)} active === id ? content : null,
</main> )}
</div>
</div>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -48,11 +48,13 @@ export const SystemSettingsForm = (): ReactElement => {
</article> </article>
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500" className={
isLoading ? "button is-danger is-loading" : "button is-danger"
}
onClick={() => flushDb()} onClick={() => flushDb()}
> >
<span className="pt-1 px-1"> <span className="icon">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i> <i className="fas fa-eraser"></i>
</span> </span>
<span>Flush DB & Temporary Folders</span> <span>Flush DB & Temporary Folders</span>
</button> </button>

View File

@@ -1,25 +1,30 @@
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash"; import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
import React, { ReactElement, useState, useCallback } from "react"; import React, { useEffect, ReactElement, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { analyzeLibrary } from "../../actions/comicinfo.actions"; import {
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query"; getComicBookDetailById,
getIssuesForSeries,
analyzeLibrary,
} from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches"; import PotentialLibraryMatches from "./PotentialLibraryMatches";
import Masonry from "react-masonry-css";
import { Card } from "../shared/Carda"; import { Card } from "../shared/Carda";
import SlidingPane from "react-sliding-pane"; import SlidingPane from "react-sliding-pane";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
const VolumeDetails = (props): ReactElement => { const VolumeDetails = (props): ReactElement => {
const breakpointColumnsObj = {
default: 6,
1100: 4,
700: 3,
500: 2,
};
// sliding panel config // sliding panel config
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [matches, setMatches] = useState([]); const [matches, setMatches] = useState([]);
const [storyArcsData, setStoryArcsData] = useState([]);
const [active, setActive] = useState(1); const [active, setActive] = useState(1);
// sliding panel init // sliding panel init
@@ -28,9 +33,7 @@ const VolumeDetails = (props): ReactElement => {
content: () => { content: () => {
const ids = map(matches, partialRight(pick, "_id")); const ids = map(matches, partialRight(pick, "_id"));
const matchIds = ids.map((id: any) => id._id); const matchIds = ids.map((id: any) => id._id);
{ return <PotentialLibraryMatches matches={matchIds} />;
/* return <PotentialLibraryMatches matches={matchIds} />; */
}
}, },
}, },
}; };
@@ -42,145 +45,68 @@ const VolumeDetails = (props): ReactElement => {
setVisible(true); setVisible(true);
}, []); }, []);
// const analyzeIssues = useCallback((issues) => { const analyzeIssues = useCallback((issues) => {
// dispatch(analyzeLibrary(issues)); dispatch(analyzeLibrary(issues));
// }, []); }, []);
//
const comicBookDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const issuesForVolume = useSelector(
(state: RootState) => state.comicInfo.issuesForVolume,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getIssuesForSeries(comicObjectId));
dispatch(getComicBookDetailById(comicObjectId));
}, []);
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { data: comicObject, isSuccess: isComicObjectFetchedSuccessfully } =
useQuery({
queryFn: async () =>
axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
queryKey: ["comicObject"],
});
// get issues for a series
const {
data: issuesForSeries,
isSuccess,
isFetching,
} = useQuery({
queryFn: async () =>
await axios({
url: `${COMICVINE_SERVICE_URI}/getIssuesForVolume`,
method: "POST",
data: {
volumeId:
comicObject?.data?.sourcedMetadata.comicvine.volumeInformation.id,
},
}),
queryKey: ["issuesForSeries", comicObject?.data],
enabled: !isUndefined(comicObject?.data),
});
// get story arcs
const useGetStoryArcs = () => {
return useMutation({
mutationFn: async (comicObject) =>
axios({
url: `${COMICVINE_SERVICE_URI}/getResource`,
method: "POST",
data: {
comicObject,
resource: "issue",
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
},
}),
onSuccess: (data) => {
setStoryArcsData(data?.data.results);
},
});
};
const {
mutate: getStoryArcs,
isIdle,
isError,
data,
error,
status,
} = useGetStoryArcs();
const IssuesInVolume = () => ( const IssuesInVolume = () => (
<> <>
{!isUndefined(issuesForSeries) ? ( {!isUndefined(issuesForVolume) ? (
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}> <div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
Analyze Library Analyze Library
</div> </div>
) : null} ) : null}
<> <Masonry
{isSuccess && breakpointCols={breakpointColumnsObj}
issuesForSeries.data.map((issue) => { className="issues-container"
return ( columnClassName="issues-column"
<> >
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
? issuesForVolume.map((issue) => {
return (
<Card <Card
key={issue.id} key={issue.id}
imageUrl={issue.image.small_url} imageUrl={issue.image.thumb_url}
orientation={"cover-only"} orientation={"vertical"}
hasDetails={false} hasDetails
/> borderColorClass={
<span className="tag is-warning mr-1"> !isEmpty(issue.matches) ? "green-border" : ""
{issue.issue_number} }
</span> backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
{!isEmpty(issue.matches) ? ( onClick={() =>
<> openPotentialLibraryMatchesPanel(issue.matches)
<span className="icon has-text-success"> }
<i className="fa-regular fa-asterisk"></i> >
</span> <span className="tag is-warning mr-1">
</> {issue.issue_number}
) : null} </span>
</> {!isEmpty(issue.matches) ? (
); <>
})} <span className="icon has-text-success">
</> <i className="fa-regular fa-asterisk"></i>
</> </span>
); </>
) : null}
const Issues = () => ( </Card>
<> );
<article })
role="alert" : "loading"}
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600" </Masonry>
>
<div>
You can add a single issue or the whole volume, and it will be added
to the list of `Wanted` items.
</div>
</article>
<div className="flex flex-wrap">
{isSuccess &&
issuesForSeries?.data.map((issue) => {
return (
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
<div className="flex flex-row gap-4 mb-2">
<div className="w-fit">
<img
src={issue.image.thumb_url}
className="w-full rounded-md"
/>
</div>
<div className="w-3/4">
<p className="text-xl">{issue.name}</p>
<p className="text-sm">
{convert(issue.description, {
baseElements: {
selectors: ["p"],
},
})}
</p>
</div>
</div>
</div>
);
})}
</div>
</> </>
); );
@@ -189,44 +115,20 @@ const VolumeDetails = (props): ReactElement => {
{ {
id: 1, id: 1,
name: "Issues in Volume", name: "Issues in Volume",
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>, icon: <i className="fa-solid fa-layer-group"></i>,
content: <Issues />, content: <IssuesInVolume key={1} />,
}, },
{ {
id: 2, id: 2,
icon: ( icon: <i className="fa-regular fa-mask"></i>,
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
),
name: "Characters", name: "Characters",
content: <div key={2}>Characters</div>, content: <div key={2}>asdasd</div>,
}, },
{ {
id: 3, id: 3,
icon: ( icon: <i className="fa-solid fa-scroll"></i>,
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i> name: "Arcs",
), content: <div key={3}>asdasd</div>,
name: "Story Arcs",
content: (
<div key={3}>
<button className="" onClick={() => getStoryArcs(comicObject?.data)}>
Get story arcs
</button>
{status === "pending" && <>{status}</>}
{!isEmpty(storyArcsData) && status === "success" && (
<>
<ul>
{storyArcsData.map((storyArc) => {
return (
<li>
<span className="text-lg">{storyArc?.name}</span>
</li>
);
})}
</ul>
</>
)}
</div>
),
}, },
]; ];
@@ -234,26 +136,21 @@ const VolumeDetails = (props): ReactElement => {
const MetadataTabGroup = () => { const MetadataTabGroup = () => {
return ( return (
<> <>
<div className="hidden sm:block mt-7 mb-3 w-fit"> <div className="tabs">
<div className="border-b border-gray-200"> <ul>
<nav className="flex gap-4" aria-label="Tabs"> {tabGroup.map(({ id, name, icon }) => (
{tabGroup.map(({ id, name, icon }) => ( <li
<a key={id}
key={id} className={id === active ? "is-active" : ""}
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${ onClick={() => setActive(id)}
active === id >
? "border-b border-cyan-50 dark:text-slate-200" <a>
: "border-b border-transparent" <span className="icon is-small">{icon}</span>
}`}
aria-current="page"
onClick={() => setActive(id)}
>
<span className="pt-1">{icon}</span>
{name} {name}
</a> </a>
))} </li>
</nav> ))}
</div> </ul>
</div> </div>
{tabGroup.map(({ id, content }) => { {tabGroup.map(({ id, content }) => {
return active === id ? content : null; return active === id ? content : null;
@@ -261,103 +158,97 @@ const VolumeDetails = (props): ReactElement => {
</> </>
); );
}; };
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
const { sourcedMetadata } = comicObject.data;
return (
<>
<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">
Volumes
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white"> if (
Browse your collection of volumes. !isUndefined(comicBookDetails.sourcedMetadata) &&
</p> !isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
</div> ) {
</div> return (
</div> <div className="container volume-details">
</header> <div className="section">
<div className="container mx-auto mt-4"> {/* Title */}
<div> <h1 className="title">
<div className="flex flex-row gap-4"> {comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
{/* Volume cover */} </h1>
<div className="columns is-multiline">
{/* Volume cover */}
<div className="column is-narrow">
<Card <Card
imageUrl={ imageUrl={
sourcedMetadata.comicvine.volumeInformation.image.small_url comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.image.small_url
} }
orientation={"cover-only"} cardContainerStyle={{ maxWidth: 275 }}
orientation={"vertical"}
hasDetails={false} hasDetails={false}
/> />
</div>
<div> <div className="column is-three-fifths">
<div className="field is-grouped"> <div className="field is-grouped mt-2">
{/* Title */} {/* Comicvine Id */}
<span className="text-2xl"> <div className="control">
{sourcedMetadata.comicvine.volumeInformation.name} <div className="tags has-addons">
</span> <span className="tag">ComicVine Id</span>
{/* Comicvine Id */} <span className="tag is-info is-light">
<div className="control"> {
<div className="tags has-addons"> comicBookDetails.sourcedMetadata.comicvine
<span className="tag">ComicVine Id</span> .volumeInformation.id
<span className="tag is-info is-light"> }
{sourcedMetadata.comicvine.volumeInformation.id} </span>
</span>
</div>
</div>
{/* Publisher */}
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning is-light">Publisher</span>
<span className="tag is-volume-related">
{
sourcedMetadata.comicvine.volumeInformation.publisher
.name
}
</span>
</div>
</div> </div>
</div> </div>
{/* Publisher */}
{/* Deck */} <div className="control">
<div> <div className="tags has-addons">
{!isEmpty( <span className="tag is-warning is-light">Publisher</span>
sourcedMetadata.comicvine.volumeInformation.description, <span className="tag is-volume-related">
) {
? ellipsize( comicBookDetails.sourcedMetadata.comicvine
convert( .volumeInformation.publisher.name
sourcedMetadata.comicvine.volumeInformation }
.description, </span>
{ </div>
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div> </div>
</div> </div>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */} {/* Deck */}
<div>
{!isEmpty(
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.description,
)
? ellipsize(
convert(
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div>
</div> </div>
<MetadataTabGroup />
</div>
<SlidingPane {/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
isOpen={visible} </div>
onRequestClose={() => setVisible(false)} <MetadataTabGroup />
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div> </div>
</>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
); );
} else { } else {
return <></>; return <></>;

View File

@@ -4,7 +4,6 @@ import Card from "../shared/Carda";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints"; import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
@@ -40,39 +39,35 @@ export const Volumes = (props): ReactElement => {
header: "Volume Details", header: "Volume Details",
id: "volumeDetails", id: "volumeDetails",
minWidth: 450, minWidth: 450,
accessorFn: (row) => row, accessorKey: "_source",
cell: (row): any => { cell: (row): any => {
const comicObject = row.getValue(); const foo = row.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
console.log("jaggu", row.getValue());
return ( return (
<div className="flex flex-row gap-3 mt-5"> <div className="flex flex-row gap-3 mt-5">
<Link to={`/volume/details/${comicObject._id}`}> <Card
<Card imageUrl={
imageUrl={ foo.sourcedMetadata.comicvine.volumeInformation.image
sourcedMetadata.comicvine.volumeInformation.image.small_url .small_url
} }
orientation={"cover-only"} orientation={"cover-only"}
hasDetails={false} hasDetails={false}
/> />
</Link> <div className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg h-fit">
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3"> <span className="text-xl mb-1">
<div className="text-xl mb-1 w-fit"> {foo.sourcedMetadata.comicvine.volumeInformation.name}
{sourcedMetadata.comicvine.volumeInformation.name} </span>
</div>
<p> <p>
{ellipsize( {ellipsize(
convert( convert(
sourcedMetadata.comicvine.volumeInformation.description, foo.sourcedMetadata.comicvine.volumeInformation
.description,
{ {
baseElements: { baseElements: {
selectors: ["p"], selectors: ["p"],
}, },
}, },
), ),
180, 120,
)} )}
</p> </p>
</div> </div>
@@ -167,7 +162,6 @@ export const Volumes = (props): ReactElement => {
nextPage: () => {}, nextPage: () => {},
previousPage: () => {}, previousPage: () => {},
}} }}
rowClickHandler={() => {}}
columns={columnData} columns={columnData}
/> />
</div> </div>

View File

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

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../shared/utils/formatting.utils";
import { isUndefined, isEmpty, isNil } from "lodash";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
import ellipsize from "ellipsize";
interface IProps {
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
mongoObjId?: number;
hasTitle: boolean;
title?: string;
isHorizontal: boolean;
}
interface IState {}
class Card extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
}
public drawCoverCard = (
metadata: IExtractedComicBookCoverFile,
): JSX.Element => {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div>
<div className="card generic-card">
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
<div className="card-image">
<figure className="image">
<img src={filePath} alt="Placeholder image" />
</figure>
</div>
{this.props.hasTitle && (
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
)}
</div>
</div>
</div>
);
};
public render() {
return (
<>
{!isUndefined(this.props.comicBookCoversMetadata) &&
!isEmpty(this.props.comicBookCoversMetadata) &&
this.drawCoverCard(this.props.comicBookCoversMetadata)}
</>
);
}
}
export default Card;

View File

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

View File

@@ -1,42 +1,71 @@
import React, { useRef, useState } from "react"; import React, { ChangeEventHandler, useRef, useState } from "react";
import { format } from "date-fns";
import { format, isValid, parse, parseISO } from "date-fns";
import FocusTrap from "focus-trap-react"; import FocusTrap from "focus-trap-react";
import { ClassNames, DayPicker } from "react-day-picker"; import { DayPicker, SelectSingleEventHandler } from "react-day-picker";
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom"; import { usePopper } from "react-popper";
import styles from "react-day-picker/dist/style.module.css";
export const DatePickerDialog = (props) => { export const DatePickerDialog = (props) => {
const { setter, apiAction } = props; const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>(); const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false); const [isPopperOpen, setIsPopperOpen] = useState(false);
const classNames: ClassNames = { const popperRef = useRef<HTMLDivElement>(null);
...styles,
head: "custom-head",
};
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const { x, y, reference, floating, strategy, refs, update } = useFloating({ const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
placement: "bottom-end", null,
middleware: [offset(10), flip()], );
strategy: "absolute",
const customStyles = {
container: {
// Style for the entire container
border: "1px solid #ccc",
borderRadius: "4px",
padding: "10px",
width: "300px",
},
day: {
// Style for individual days
padding: "5px",
margin: "2px",
},
selected: {
// Style for selected days
backgroundColor: "#007bff",
color: "#fff",
},
disabled: {
// Style for disabled days
color: "#ccc",
},
today: {
// Style for today's date
backgroundColor: "#f0f0f0",
},
dayWrapper: {
// Style for the wrapper around each day
display: "inline-block",
},
};
const popper = usePopper(popperRef.current, popperElement, {
placement: "bottom-start",
}); });
const closePopper = () => { const closePopper = () => {
setIsPopperOpen(false); setIsPopperOpen(false);
buttonRef.current?.focus(); buttonRef?.current?.focus();
}; };
const handleButtonClick = () => { const handleButtonClick = () => {
setIsPopperOpen(true); setIsPopperOpen(true);
if (refs.reference.current && refs.floating.current) {
autoUpdate(refs.reference.current, refs.floating.current, update);
}
}; };
const handleDaySelect = (date) => { const handleDaySelect: SelectSingleEventHandler = (date) => {
setSelected(date); setSelected(date);
if (date) { if (date) {
setter(format(date, "yyyy/MM/dd")); setter(format(date, "M-dd-yyyy"));
apiAction(); apiAction();
closePopper(); closePopper();
} else { } else {
@@ -46,14 +75,17 @@ export const DatePickerDialog = (props) => {
return ( return (
<div> <div>
<div ref={reference}> <div ref={popperRef}>
<button <button
ref={buttonRef} ref={buttonRef}
type="button" type="button"
aria-label="Pick a date" aria-label="Pick a date"
onClick={handleButtonClick} onClick={handleButtonClick}
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" className="flex space-x-1 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"
> >
<span className="pr-1 pt-0.5 h-8">
<span className="icon-[solar--calendar-date-bold-duotone] w-6 h-6"></span>
</span>
Pick a date Pick a date
</button> </button>
</div> </div>
@@ -69,14 +101,11 @@ export const DatePickerDialog = (props) => {
}} }}
> >
<div <div
ref={floating} tabIndex={-1}
style={{ style={popper.styles.popper}
position: strategy, className="bg-slate-200 mt-3 p-2 rounded-lg z-50"
zIndex: "999", {...popper.attributes.popper}
borderRadius: "10px", ref={setPopperElement}
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
}}
className="bg-slate-400 dark:bg-slate-500"
role="dialog" role="dialog"
aria-label="DayPicker calendar" aria-label="DayPicker calendar"
> >
@@ -86,7 +115,7 @@ export const DatePickerDialog = (props) => {
defaultMonth={selected} defaultMonth={selected}
selected={selected} selected={selected}
onSelect={handleDaySelect} onSelect={handleDaySelect}
classNames={classNames} styles={customStyles}
/> />
</div> </div>
</FocusTrap> </FocusTrap>

View File

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

View File

@@ -0,0 +1,312 @@
import React from "react";
import { SearchBar } from "../GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns";
import { useStore } from "../../store/index";
import { useShallow } from "zustand/react/shallow";
const Navbar: React.FunctionComponent = (props) => {
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPDownloadTick,
importJobQueue,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
importJobQueue: state.importJobQueue,
})),
);
// const downloadProgressTick = useSelector(
// (state: RootState) => state.airdcpp.downloadProgressData,
// );
//
// const airDCPPSocketConnectionStatus = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
// );
// const airDCPPSessionInfo = useSelector(
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
// );
// const socketDisconnectionReason = useSelector(
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
// );
return (
<nav className="navbar is-fixed-top">
<div className="navbar-brand">
<Link to="/dashboard" className="navbar-item">
<img
src="/src/client/assets/img/threetwo.svg"
alt="ThreeTwo! A comic book curator"
width="112"
height="28"
/>
</Link>
<a className="navbar-item is-hidden-desktop">
<span className="icon">
<i className="fas fa-github"></i>
</span>
</a>
<a className="navbar-item is-hidden-desktop">
<span className="icon">
<i className="fas fa-twitter"></i>
</span>
</a>
<div className="navbar-burger burger" data-target="navMenubd-example">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div id="navMenubd-example" className="navbar-menu">
<div className="navbar-start">
<Link to="/" className="navbar-item">
Dashboard
</Link>
<Link to="/import" className="navbar-item">
Import
</Link>
<Link to="/library" className="navbar-item">
Library
</Link>
<Link to="/downloads" className="navbar-item">
Downloads
</Link>
{/* <SearchBar /> */}
<Link to="/search" className="navbar-item">
Search ComicVine
</Link>
</div>
<div className="navbar-end">
<a className="navbar-item is-hidden-desktop-only"></a>
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i>
{!isEmpty(airDCPPDownloadTick) && (
<div className="pulsating-circle"></div>
)}
</a>
{!isEmpty(airDCPPDownloadTick) ? (
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<DownloadProgressTick data={airDCPPDownloadTick} />
</a>
</div>
) : null}
</div>
{!isUndefined(importJobQueue.status) &&
location.hash !== "#/import" ? (
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
</a>
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<ul>
{importJobQueue.successfulJobCount > 0 ? (
<li className="mb-2">
<span className="tag is-success mr-2">
{importJobQueue.successfulJobCount}
</span>
imported.
</li>
) : null}
{importJobQueue.failedJobCount > 0 ? (
<li>
<span className="tag is-danger mr-2">
{importJobQueue.failedJobCount}
</span>
failed to import.
</li>
) : null}
</ul>
</a>
</div>
</div>
) : null}
{/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{!isUndefined(airDCPPSessionInformation.user) ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
{/* AirDC++ Session Information */}
<p>
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(
airDCPPSessionInformation?.user.last_login,
),
"dd MMMM, yyyy",
)}
</span>
</p>
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInformation.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.session_id}
</span>
</p>
</div>
</>
) : (
<>
<a className="navbar-link is-arrowless has-text-danger">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
</div>
</>
)}
</div>
<div className="navbar-item has-dropdown is-hoverable is-mega">
<div className="navbar-link flex">Blog</div>
<div id="blogDropdown" className="navbar-dropdown">
<div className="container is-fluid">
<div className="columns">
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a
className="navbar-item "
href="http://bulma.io/documentation/columns/basics/"
>
Columns
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a
className="navbar-item "
href="/documentation/overview/start/"
>
Overview
</a>
</div>
</div>
</div>
<hr className="navbar-divider" />
<div className="navbar-item">
<div className="navbar-content">
<div className="level is-mobile">
<div className="level-left">
<div className="level-item">
<strong>Stay up to date!</strong>
</div>
</div>
<div className="level-right">
<div className="level-item">
<a
className="button bd-is-rss is-small"
href="http://bulma.io/atom.xml"
>
<span className="icon is-small">
<i className="fa fa-rss"></i>
</span>
<span>Subscribe</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="navbar-item">
<div className="field is-grouped">
<p className="control">
<Link to="/settings" className="navbar-item">
Settings
</Link>
</p>
</div>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View File

@@ -98,7 +98,7 @@ export const Navbar2 = (): ReactElement => {
<li> <li>
{/* Light/Dark Mode toggle */} {/* Light/Dark Mode toggle */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-gray-600 dark:text-white">Dark</span> <span className="text-gray-600 dark:text-white">Light</span>
<label <label
htmlFor="toggle" htmlFor="toggle"
className="relative inline-flex items-center" className="relative inline-flex items-center"
@@ -117,7 +117,7 @@ export const Navbar2 = (): ReactElement => {
}`} }`}
></span> ></span>
</label> </label>
<span className="text-gray-600 dark:text-white">Light</span> <span className="text-gray-600 dark:text-white">Dark</span>
</div> </div>
</li> </li>
</ul> </ul>

View File

@@ -1,45 +0,0 @@
import React, { useState } from "react";
import { useFloating, offset, flip } from "@floating-ui/react-dom";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
const PopoverButton = ({ content, clickHandler }) => {
const [isVisible, setIsVisible] = useState(false);
// Use destructuring to obtain the reference and floating setters, among other values.
const { x, y, refs, strategy, floatingStyles } = useFloating({
placement: "right",
middleware: [offset(8), flip()],
strategy: "absolute",
});
const { t } = useTranslation();
return (
<div>
{/* Apply the reference setter directly to the ref prop */}
<button
ref={refs.setReference}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
aria-describedby="popover"
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>{" "}
Mark as Wanted
</button>
{isVisible && (
<div
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
style={floatingStyles}
className="text-xs bg-slate-400 p-1.5 rounded-md"
role="tooltip"
>
{content}
</div>
)}
</div>
);
};
export default PopoverButton;

View File

@@ -1,4 +1,5 @@
import React, { ReactElement, useMemo, useState } from "react"; import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -8,19 +9,7 @@ import {
PaginationState, PaginationState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
interface T2TableProps { export const T2Table = (tableOptions): ReactElement => {
sourceData?: unknown[];
totalPages?: number;
columns?: unknown[];
paginationHandlers?: {
nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown;
};
rowClickHandler?(...args: unknown[]): unknown;
children?: any;
}
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const { const {
sourceData, sourceData,
columns, columns,
@@ -71,7 +60,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
columns, columns,
manualPagination: true, manualPagination: true,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
pageCount: sourceData?.length ?? -1, pageCount: sourceData.length ?? -1,
state: { state: {
pagination, pagination,
}, },
@@ -81,47 +70,42 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
return ( return (
<div className="container max-w-fit mx-14"> <div className="container max-w-fit mx-14">
<div> <div>
<div className="flex flex-row gap-2 justify-between mt-6 mb-4"> <div className="flex flex-row gap-2 justify-between mt-7">
{/* Search bar */} {/* Search bar */}
{tableOptions.children} {tableOptions.children}
{/* pagination controls */}
{/* Pagination controls */} <div>
<div className="text-sm text-gray-800 dark:text-slate-200"> Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
<div className="mb-1"> <p>{totalPages} comics in all</p>
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} {/* Prev/Next buttons */}
</div> <div className="inline-flex flex-row mt-4 mb-4">
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button <button
onClick={() => goToPreviousPage()} onClick={() => goToPreviousPage()}
disabled={pageIndex === 1} disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600" className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2"
> >
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i> <i className="icon-[solar--arrow-left-linear] h-6 w-6"></i>
</button> </button>
<button <button
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1" className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2"
onClick={() => goToNextPage()} onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)} disabled={pageIndex > Math.floor(totalPages / pageSize)}
> >
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i> <i className="icon-[solar--arrow-right-linear] h-6 w-6"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<table className="table-auto overflow-auto">
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100 border-separate border-spacing-0"> <thead className="sticky top-0 bg-slate-200 dark:bg-slate-500">
<thead className="sticky top-0 bg-white dark:bg-slate-900 z-10 border-b border-gray-300 dark:border-slate-700"> {table.getHeaderGroups().map((headerGroup, idx) => (
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header, idx) => (
<th <th
key={header.id} key={header.id}
colSpan={header.colSpan} colSpan={header.colSpan}
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400" className="px-3 py-3"
> >
{header.isPlaceholder {header.isPlaceholder
? null ? null
@@ -136,23 +120,37 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row, idx) => {
<tr return (
key={row.id} <tr key={row.id} onClick={() => rowClickHandler(row)}>
onClick={() => rowClickHandler(row)} {row.getVisibleCells().map((cell) => {
className="border-b border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors" return (
> <td key={cell.id} className="align-top">
{row.getVisibleCells().map((cell) => ( {flexRender(
<td key={cell.id} className="px-3 py-2 align-top"> cell.column.columnDef.cell,
{flexRender(cell.column.columnDef.cell, cell.getContext())} cell.getContext(),
</td> )}
))} </td>
</tr> );
))} })}
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
); );
}; };
T2Table.propTypes = {
sourceData: PropTypes.array,
totalPages: PropTypes.number,
columns: PropTypes.array,
paginationHandlers: PropTypes.shape({
nextPage: PropTypes.func,
previousPage: PropTypes.func,
}),
rowClickHandler: PropTypes.func,
children: PropTypes.any,
};
export default T2Table; export default T2Table;

View File

@@ -104,10 +104,3 @@ export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
port: "3000", port: "3000",
apiPath: `/api/torrentjobs`, apiPath: `/api/torrentjobs`,
}); });
export const AIRDCPP_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/airdcpp`,
});

View File

@@ -9,8 +9,8 @@
"displayName": "Dashboard" "displayName": "Dashboard"
}, },
{ {
"id": "gen-docker-vars", "id": "gen-gls",
"displayName": "Docker ENV vars" "displayName": "Global Search"
} }
] ]
}, },

View File

@@ -0,0 +1,11 @@
import React, { createContext } from "react";
export const SocketIOContext = createContext({});
export const SocketIOProvider = ({ children, socket }) => {
return (
<SocketIOContext.Provider value={socket}>
{children}
</SocketIOContext.Provider>
);
};

View File

@@ -0,0 +1,16 @@
import React, { useEffect } from "react";
import BlazeSlider from "blaze-slider";
export const useBlazeSlider = (config) => {
const sliderRef = React.useRef();
const elRef = React.useRef();
useEffect(() => {
// if not already initialized
if (!sliderRef.current) {
sliderRef.current = new BlazeSlider(elRef.current, config);
}
}, []);
return elRef;
};

View File

@@ -7,7 +7,6 @@ import Settings from "./components/Settings/Settings";
import { ErrorPage } from "./components/shared/ErrorPage"; import { ErrorPage } from "./components/shared/ErrorPage";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
const root = createRoot(rootEl); const root = createRoot(rootEl);
import i18n from "./shared/utils/i18n.util";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Import from "./components/Import/Import"; import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard"; import Dashboard from "./components/Dashboard/Dashboard";
@@ -15,7 +14,6 @@ import Search from "./components/Search/Search";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer"; import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer"; import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
import Volumes from "./components/Volumes/Volumes"; import Volumes from "./components/Volumes/Volumes";
import VolumeDetails from "./components/VolumeDetail/VolumeDetail";
import WantedComics from "./components/WantedComics/WantedComics"; import WantedComics from "./components/WantedComics/WantedComics";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -39,7 +37,6 @@ const router = createBrowserRouter([
}, },
{ path: "import", element: <Import path={"./comics"} /> }, { path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> }, { path: "search", element: <Search /> },
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
{ path: "volumes", element: <Volumes /> }, { path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> }, { path: "wanted", element: <WantedComics /> },
], ],

View File

@@ -1,4 +0,0 @@
{
"issueWithCount_one": "{{count}} issue",
"issueWithCount_other": "{{count}} issues"
}

View File

@@ -0,0 +1,133 @@
import {
AIRDCPP_SEARCH_IN_PROGRESS,
AIRDCPP_SEARCH_RESULTS_ADDED,
AIRDCPP_SEARCH_RESULTS_UPDATED,
AIRDCPP_HUB_SEARCHES_SENT,
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
AIRDCPP_BUNDLES_FETCHED,
AIRDCPP_TRANSFERS_FETCHED,
LIBRARY_ISSUE_BUNDLES,
AIRDCPP_SOCKET_CONNECTED,
AIRDCPP_SOCKET_DISCONNECTED,
} from "../constants/action-types";
import { LOCATION_CHANGE } from "redux-first-history";
import { isNil, isUndefined } from "lodash";
import { difference } from "../shared/utils/object.utils";
const initialState = {
searchResults: [],
isAirDCPPSearchInProgress: false,
searchInfo: null,
searchInstance: null,
downloadResult: null,
bundleDBImportResult: null,
downloadFileStatus: {},
bundles: [],
transfers: [],
isAirDCPPSocketConnected: false,
airDCPPSessionInfo: {},
socketDisconnectionReason: {},
};
function airdcppReducer(state = initialState, action) {
switch (action.type) {
case AIRDCPP_SEARCH_RESULTS_ADDED:
return {
...state,
searchResults: [...state.searchResults, action.groupedResult],
isAirDCPPSearchInProgress: true,
};
case AIRDCPP_SEARCH_RESULTS_UPDATED:
const bundleToUpdateIndex = state.searchResults.findIndex(
(bundle) => bundle.result.id === action.groupedResult.result.id,
);
const updatedState = [...state.searchResults];
if (
!isNil(
difference(updatedState[bundleToUpdateIndex], action.groupedResult),
)
) {
updatedState[bundleToUpdateIndex] = action.groupedResult;
}
return {
...state,
searchResults: updatedState,
};
case AIRDCPP_SEARCH_IN_PROGRESS:
return {
...state,
isAirDCPPSearchInProgress: true,
};
case AIRDCPP_HUB_SEARCHES_SENT:
return {
...state,
isAirDCPPSearchInProgress: false,
searchInfo: action.searchInfo,
searchInstance: action.instance,
};
case AIRDCPP_RESULT_DOWNLOAD_INITIATED:
return {
...state,
downloadResult: action.downloadResult,
bundleDBImportResult: action.bundleDBImportResult,
};
case AIRDCPP_DOWNLOAD_PROGRESS_TICK:
return {
...state,
downloadProgressData: action.downloadProgressData,
};
case AIRDCPP_BUNDLES_FETCHED:
return {
...state,
bundles: action.bundles,
};
case LIBRARY_ISSUE_BUNDLES:
return {
...state,
issue_bundles: action.issue_bundles,
};
case AIRDCPP_FILE_DOWNLOAD_COMPLETED:
console.log("COMPLETED", action);
return {
...state,
};
case AIRDCPP_TRANSFERS_FETCHED:
return {
...state,
transfers: action.bundles,
};
case AIRDCPP_SOCKET_CONNECTED:
return {
...state,
isAirDCPPSocketConnected: true,
airDCPPSessionInfo: action.data,
};
case AIRDCPP_SOCKET_DISCONNECTED:
return {
...state,
isAirDCPPSocketConnected: false,
socketDisconnectionReason: action.data,
};
case LOCATION_CHANGE:
return {
...state,
searchResults: [],
isAirDCPPSearchInProgress: false,
searchInfo: null,
searchInstance: null,
downloadResult: null,
bundleDBImportResult: null,
// bundles: [],
};
default:
return state;
}
}
export default airdcppReducer;

View File

@@ -0,0 +1,138 @@
import { isEmpty } from "lodash";
import {
CV_API_CALL_IN_PROGRESS,
CV_SEARCH_SUCCESS,
CV_CLEANUP,
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
CV_WEEKLY_PULLLIST_FETCHED,
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
LIBRARY_STATISTICS_FETCHED,
} from "../constants/action-types";
const initialState = {
pullList: [],
libraryStatistics: [],
searchResults: [],
searchQuery: {},
inProgress: false,
comicBookDetail: {},
comicBooksDetails: [],
issuesForVolume: [],
IMS_inProgress: false,
};
function comicinfoReducer(state = initialState, action) {
switch (action.type) {
case CV_API_CALL_IN_PROGRESS:
return {
...state,
inProgress: true,
};
case CV_SEARCH_SUCCESS:
return {
...state,
searchResults: action.searchResults,
searchQuery: action.searchQueryObject,
inProgress: false,
};
case IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS:
return {
...state,
IMS_inProgress: true,
};
case IMS_COMIC_BOOK_DB_OBJECT_FETCHED:
return {
...state,
comicBookDetail: action.comicBookDetail,
IMS_inProgress: false,
};
case IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED:
return {
...state,
comicBooksDetails: action.comicBooks,
IMS_inProgress: false,
};
case CV_CLEANUP:
return {
...state,
searchResults: [],
searchQuery: {},
issuesForVolume: [],
};
case CV_ISSUES_METADATA_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS:
return {
...state,
issuesForVolume: action.issues,
inProgress: false,
};
case CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED:
const updatedState = [...state.issuesForVolume];
action.matches.map((match) => {
updatedState.map((issue, idx) => {
const matches = [];
if (!isEmpty(match.hits.hits)) {
return match.hits.hits.map((hit) => {
if (
parseInt(issue.issue_number, 10) ===
hit._source.inferredMetadata.issue.number
) {
matches.push(hit);
const updatedIssueResult = { ...issue, matches };
updatedState[idx] = updatedIssueResult;
}
});
}
});
});
return {
...state,
issuesForVolume: updatedState,
};
case CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case CV_WEEKLY_PULLLIST_FETCHED: {
const foo = [];
action.data.map((item) => {
foo.push({issue: item})
});
return {
...state,
inProgress: false,
pullList: foo,
};
}
case LIBRARY_STATISTICS_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case LIBRARY_STATISTICS_FETCHED:
return {
...state,
inProgress: false,
libraryStatistics: action.data,
};
default:
return state;
}
}
export default comicinfoReducer;

View File

@@ -0,0 +1,334 @@
import { isUndefined, map } from "lodash";
import { LOCATION_CHANGE } from "redux-first-history";
import { determineCoverFile } from "../shared/utils/metadata.utils";
import {
IMS_COMICBOOK_METADATA_FETCHED,
IMS_RAW_IMPORT_SUCCESSFUL,
IMS_RAW_IMPORT_FAILED,
IMS_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED,
WANTED_COMICS_FETCHED,
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
IMS_CV_METADATA_IMPORT_FAILED,
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
LS_IMPORT,
LS_COVER_EXTRACTED,
LS_COVER_EXTRACTION_FAILED,
LS_COMIC_ADDED,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
VOLUMES_FETCHED,
COMICBOOK_EXTRACTION_SUCCESS,
LIBRARY_SERVICE_HEALTH,
LS_IMPORT_QUEUE_DRAINED,
LS_SET_QUEUE_STATUS,
RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
const initialState = {
IMSCallInProgress: false,
IMGCallInProgress: false,
SSCallInProgress: false,
imageAnalysisResults: {},
comicBookExtractionInProgress: false,
LSQueueImportStatus: undefined,
comicBookMetadata: [],
comicVolumeGroups: [],
isSocketConnected: false,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportError: {},
rawImportError: {},
extractedComicBookArchive: {
reading: [],
analysis: [],
},
recentComics: [],
wantedComics: [],
libraryComics: [],
volumes: [],
librarySearchResultsFormatted: [],
lastQueueJob: "",
successfulJobCount: 0,
failedJobCount: 0,
importJobStatistics: [],
libraryQueueResults: [],
librarySearchError: {},
libraryServiceStatus: {},
};
function fileOpsReducer(state = initialState, action) {
switch (action.type) {
case IMS_COMICBOOK_METADATA_FETCHED:
return {
...state,
comicBookMetadata: [...state.comicBookMetadata, action.data],
IMSCallInProgress: false,
};
case LS_IMPORT_CALL_IN_PROGRESS: {
return {
...state,
IMSCallInProgress: true,
};
}
case IMS_RAW_IMPORT_SUCCESSFUL:
return {
...state,
rawImportDetails: action.rawImportDetails,
};
case IMS_RAW_IMPORT_FAILED:
return {
...state,
rawImportErorr: action.rawImportError,
};
case IMS_RECENT_COMICS_FETCHED:
return {
...state,
recentComics: action.data.docs,
};
case IMS_WANTED_COMICS_FETCHED:
return {
...state,
wantedComics: action.data,
};
case IMS_CV_METADATA_IMPORT_SUCCESSFUL:
return {
...state,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportDetails: action.importResult,
};
case IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS:
return {
...state,
isComicVineMetadataImportInProgress: true,
};
case IMS_CV_METADATA_IMPORT_FAILED:
return {
...state,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportError: action.importError,
};
case IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS: {
return {
...state,
IMSCallInProgress: true,
};
}
case IMS_COMIC_BOOK_GROUPS_FETCHED: {
return {
...state,
comicVolumeGroups: action.data,
IMSCallInProgress: false,
};
}
case IMS_COMIC_BOOK_GROUPS_CALL_FAILED: {
return {
...state,
IMSCallInProgress: false,
error: action.error,
};
}
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS: {
return {
...state,
comicBookExtractionInProgress: true,
};
}
case LOCATION_CHANGE: {
return {
...state,
extractedComicBookArchive: [],
};
}
case LS_IMPORT: {
return {
...state,
LSQueueImportStatus: "running",
};
}
case LS_COVER_EXTRACTED: {
if (state.recentComics.length === 5) {
state.recentComics.pop();
}
return {
...state,
successfulJobCount: action.completedJobCount,
lastQueueJob: action.importResult.rawFileDetails.name,
recentComics: [...state.recentComics, action.importResult],
};
}
case LS_COVER_EXTRACTION_FAILED: {
return {
...state,
failedJobCount: action.failedJobCount,
};
}
case LS_IMPORT_QUEUE_DRAINED: {
localStorage.removeItem("sessionId");
return {
...state,
LSQueueImportStatus: "drained",
};
}
case RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION: {
console.log("Restoring state for an active import in progress...");
return {
...state,
successfulJobCount: action.completedJobCount,
failedJobCount: action.failedJobCount,
LSQueueImportStatus: action.queueStatus,
};
}
case LS_SET_QUEUE_STATUS: {
return {
...state,
LSQueueImportStatus: action.data.queueStatus,
};
}
case LS_IMPORT_JOB_STATISTICS_FETCHED: {
return {
...state,
importJobStatistics: action.data,
};
}
case COMICBOOK_EXTRACTION_SUCCESS: {
const comicBookPages: string[] = [];
map(action.result.files, (page) => {
const pageFilePath = removeLeadingPeriod(page);
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
comicBookPages.push(imagePath);
});
switch (action.result.purpose) {
case "reading":
return {
...state,
extractedComicBookArchive: {
reading: comicBookPages,
},
comicBookExtractionInProgress: false,
};
case "analysis":
return {
...state,
extractedComicBookArchive: {
analysis: comicBookPages,
},
comicBookExtractionInProgress: false,
};
}
}
case LS_COMIC_ADDED: {
return {
...state,
};
}
case IMG_ANALYSIS_CALL_IN_PROGRESS: {
return {
...state,
IMGCallInProgress: true,
};
}
case IMG_ANALYSIS_DATA_FETCH_SUCCESS: {
return {
...state,
imageAnalysisResults: action.result,
};
}
case SS_SEARCH_IN_PROGRESS: {
return {
...state,
SSCallInProgress: true,
};
}
case SS_SEARCH_RESULTS_FETCHED: {
return {
...state,
libraryComics: action.data,
SSCallInProgress: false,
};
}
case SS_SEARCH_RESULTS_FETCHED_SPECIAL: {
const foo = [];
if (!isUndefined(action.data.hits)) {
map(action.data.hits, ({ _source }) => {
foo.push(_source);
});
}
return {
...state,
librarySearchResultsFormatted: foo,
SSCallInProgress: false,
};
}
case WANTED_COMICS_FETCHED: {
const foo = [];
if (!isUndefined(action.data.hits)) {
map(action.data.hits, ({ _source }) => {
foo.push(_source);
});
}
return {
...state,
wantedComics: foo,
SSCallInProgress: false,
};
}
case VOLUMES_FETCHED:
return {
...state,
volumes: action.data,
SSCallInProgress: false,
};
case SS_SEARCH_FAILED: {
return {
...state,
librarySearchError: action.data,
SSCallInProgress: false,
};
}
case LIBRARY_SERVICE_HEALTH: {
return {
...state,
libraryServiceStatus: action.status,
};
}
case FILEOPS_STATE_RESET: {
return {
...state,
imageAnalysisResults: {},
};
}
default:
return state;
}
}
export default fileOpsReducer;

View File

@@ -0,0 +1,11 @@
import comicinfoReducer from "../reducers/comicinfo.reducer";
import fileOpsReducer from "../reducers/fileops.reducer";
import airdcppReducer from "../reducers/airdcpp.reducer";
// import settingsReducer from "../reducers/settings.reducer";
export const reducers = {
comicInfo: comicinfoReducer,
fileOps: fileOpsReducer,
airdcpp: airdcppReducer,
// settings: settingsReducer,
};

View File

@@ -0,0 +1,67 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { RootState } from "../store";
import { isUndefined } from "lodash";
import { SETTINGS_SERVICE_BASE_URI } from "../constants/endpoints";
export interface InitialState {
data: object;
inProgress: boolean;
dbFlushed: boolean;
torrentsList: Array<any>;
}
const initialState: InitialState = {
data: {},
inProgress: false,
dbFlushed: false,
torrentsList: [],
};
export const settingsSlice = createSlice({
name: "settings",
initialState,
reducers: {
SETTINGS_CALL_IN_PROGRESS: (state) => {
state.inProgress = true;
},
SETTINGS_OBJECT_FETCHED: (state, action) => {
state.data = action.payload;
state.inProgress = false;
},
SETTINGS_OBJECT_DELETED: (state, action) => {
state.data = action.payload;
state.inProgress = false;
},
SETTINGS_DB_FLUSH_SUCCESS: (state, action) => {
state.dbFlushed = action.payload;
state.inProgress = false;
},
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED: (state, action) => {
console.log(state);
console.log(action);
state.torrentsList = action.payload;
},
},
});
export const {
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_OBJECT_FETCHED,
SETTINGS_OBJECT_DELETED,
SETTINGS_DB_FLUSH_SUCCESS,
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} = settingsSlice.actions;
// Other code such as selectors can use the imported `RootState` type
export const torrentsList = (state: RootState) => state.settings.torrentsList;
export const qBittorrentSettings = (state: RootState) => {
console.log(state);
if (!isUndefined(state.settings?.data?.bittorrent)) {
return state.settings?.data?.bittorrent.client.host;
}
};
export default settingsSlice.reducer;

View File

@@ -0,0 +1,11 @@
const socketIOMiddleware = (socket) => {
return (store) => (next) => (action) => {
if (action.type === "EMIT_SOCKET_EVENT") {
const { event, data } = action.payload;
socket.emit(event, data);
}
return next(action);
};
};
export default socketIOMiddleware;

View File

@@ -0,0 +1,10 @@
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../../constants/endpoints";
const sessionId = localStorage.getItem("sessionId");
const socketIOConnectionInstance = io(SOCKET_BASE_URI, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
export default socketIOConnectionInstance;

View File

@@ -1,25 +0,0 @@
// i18n.js
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
// Learn more about options: https://www.i18next.com/overview/configuration-options
.use(HttpBackend) // Load translations over http
.use(LanguageDetector) // Detect language automatically
.use(initReactI18next) // Pass i18n instance to react-i18next
.init({
lng: "en", // Specify the language
fallbackLng: "en",
debug: true,
interpolation: {
escapeValue: false, // Not needed for React
},
backend: {
// path where resources get loaded from
loadPath: "./src/client/locales/en/translation.json",
},
});
export default i18n;

View File

@@ -1,12 +1,11 @@
import { filter, isEmpty, isNil, isUndefined, min, minBy } from "lodash"; import { filter, isEmpty, isUndefined, min, minBy } from "lodash";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints"; import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "./formatting.utils"; import { escapePoundSymbol } from "./formatting.utils";
export const determineCoverFile = (data): any => { export const determineCoverFile = (data) => {
/* For a payload like this: /* For a payload like this:
const foo = { const foo = {
rawFileDetails: {}, // #1 rawFileDetails: {}, // #1
wanted: {},
comicInfo: {}, comicInfo: {},
comicvine: {}, // #2 comicvine: {}, // #2
locg: {}, // #3 locg: {}, // #3
@@ -20,44 +19,36 @@ export const determineCoverFile = (data): any => {
issueName: "", issueName: "",
publisher: "", publisher: "",
}, },
wanted: {
objectReference: "wanted",
priority: 2,
url: "",
issueName: "",
publisher: "",
},
comicvine: { comicvine: {
objectReference: "comicvine", objectReference: "comicvine",
priority: 3, priority: 2,
url: "", url: "",
issueName: "", issueName: "",
publisher: "", publisher: "",
}, },
locg: { locg: {
objectReference: "locg", objectReference: "locg",
priority: 4, priority: 3,
url: "", url: "",
issueName: "", issueName: "",
publisher: "", publisher: "",
}, },
}; };
// comicvine if (
if (!isEmpty(data.comicvine)) { !isUndefined(data.comicvine) &&
coverFile.comicvine.url = data?.comicvine?.image.small_url; !isUndefined(data.comicvine.volumeInformation)
coverFile.comicvine.issueName = data.comicvine?.name; ) {
coverFile.comicvine.publisher = data.comicvine?.publisher?.name; coverFile.comicvine.url = data.comicvine.image.small_url;
coverFile.comicvine.issueName = data.comicvine.name;
coverFile.comicvine.publisher = data.comicvine.volumeInformation.publisher;
} }
// rawFileDetails if (!isEmpty(data.rawFileDetails.cover)) {
if (!isEmpty(data.rawFileDetails)) {
const encodedFilePath = encodeURI( const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`, `${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
); );
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath); coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
coverFile.rawFile.issueName = data.rawFileDetails.name; coverFile.rawFile.issueName = data.rawFileDetails.name;
} }
// wanted
if (!isUndefined(data.locg)) { if (!isUndefined(data.locg)) {
coverFile.locg.url = data.locg.cover; coverFile.locg.url = data.locg.cover;
coverFile.locg.issueName = data.locg.name; coverFile.locg.issueName = data.locg.name;
@@ -78,30 +69,25 @@ export const determineCoverFile = (data): any => {
export const determineExternalMetadata = ( export const determineExternalMetadata = (
metadataSource: string, metadataSource: string,
source: any, source: any
): any => { ) => {
if (!isNil(source)) { switch (metadataSource) {
switch (metadataSource) { case "comicvine":
case "comicvine": return {
return { coverURL: source.comicvine.image.small_url,
coverURL: issue: source.comicvine.name,
source.comicvine?.image.small_url || icon: "cvlogo.svg",
source.comicvine.volumeInformation?.image.small_url, };
issue: source.comicvine.name, case "locg":
icon: "cvlogo.svg", return {
}; coverURL: source.locg.cover,
case "locg": issue: source.locg.name,
return { icon: "locglogo.svg",
coverURL: source.locg.cover, };
issue: source.locg.name, case undefined:
icon: "locglogo.svg", return {};
};
case undefined:
return {};
default: default:
break; break;
}
} }
return null; };
};

View File

@@ -16,7 +16,6 @@ export const detectIssueTypes = (deck: string): any => {
const matches = map(issueTypeMatchers, (matcher) => { const matches = map(issueTypeMatchers, (matcher) => {
return getIssueTypeDisplayName(deck, matcher.regex, matcher.displayName); return getIssueTypeDisplayName(deck, matcher.regex, matcher.displayName);
}); });
return compact(matches)[0]; return compact(matches)[0];
}; };

View File

@@ -1,177 +1,271 @@
import { create } from "zustand"; import { create } from "zustand";
import io, { Socket } from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints";
import { isNil } from "lodash"; import { isNil } from "lodash";
import { toast } from "react-toastify"; import io from "socket.io-client";
import "react-toastify/dist/ReactToastify.min.css"; import { SOCKET_BASE_URI } from "../constants/endpoints";
import { produce } from "immer";
import AirDCPPSocket from "../services/DcppSearchService";
import axios from "axios";
import { QueryClient } from "@tanstack/react-query";
// Type for global state /* Broadly, this file sets up:
interface StoreState { * 1. The zustand-based global client state
socketInstances: Record<string, Socket>; * 2. socket.io client
getSocket: (namespace?: string) => Socket; * 3. AirDC++ websocket connection
disconnectSocket: (namespace: string) => void; */
queryClientRef: { current: any } | null; export const useStore = create((set, get) => ({
setQueryClientRef: (ref: any) => void; // AirDC++ state
airDCPPSocketInstance: {},
comicvine: { airDCPPSocketConnected: false,
scrapingStatus: string; airDCPPDisconnectionInfo: {},
}; airDCPPClientConfiguration: {},
airDCPPSessionInformation: {},
importJobQueue: { setAirDCPPSocketConnectionStatus: () =>
successfulJobCount: number; set((value) => ({
failedJobCount: number; airDCPPSocketConnected: value,
status: string | undefined; })),
mostRecentImport: string | null; airDCPPDownloadTick: {},
airDCPPTransfers: {},
setStatus: (status: string) => void; // Socket.io state
setJobCount: (jobType: string, count: number) => void; socketIOInstance: {},
setMostRecentImport: (fileName: string) => void;
};
}
export const useStore = create<StoreState>((set, get) => ({
socketInstances: {},
queryClientRef: null,
setQueryClientRef: (ref: any) => set({ queryClientRef: ref }),
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",
},
}));
const queryClientRef = get().queryClientRef;
if (queryClientRef?.current) {
queryClientRef.current.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 Scraping status
comicvine: { comicvine: {
scrapingStatus: "", scrapingStatus: "",
}, },
// Import job queue and associated statuses
importJobQueue: { importJobQueue: {
successfulJobCount: 0, successfulJobCount: 0,
failedJobCount: 0, failedJobCount: 0,
status: undefined, status: undefined,
mostRecentImport: null,
setStatus: (status: string) => setStatus: (status: string) =>
set((state) => ({ set(
importJobQueue: { produce((draftState) => {
...state.importJobQueue, draftState.importJobQueue.status = status;
status, }),
}, ),
})), setJobCount: (jobType: string, count: Number) => {
switch (jobType) {
case "successful":
set(
produce((draftState) => {
draftState.importJobQueue.successfulJobCount = count;
}),
);
break;
setJobCount: (jobType: string, count: number) => case "failed":
set((state) => ({ set(
importJobQueue: { produce((draftState) => {
...state.importJobQueue, draftState.importJobQueue.failedJobCount = count;
...(jobType === "successful" }),
? { successfulJobCount: count } );
: { failedJobCount: count }), break;
}, }
})), },
mostRecentImport: null,
setMostRecentImport: (fileName: string) => setMostRecentImport: (fileName: string) => {
set((state) => ({ set(
importJobQueue: { produce((state) => {
...state.importJobQueue, state.importJobQueue.mostRecentImport = fileName;
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",
{
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;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
mostRecentImport: importResult.rawFileDetails.name,
},
}));
});
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
const { failedJobCount } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
failedJobCount,
},
}));
});
// 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",
},
}));
console.log("a", queryClient);
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
// ComicVine Scraping status
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
setState((state) => ({
comicvine: {
...state.comicvine,
scrapingStatus: data.message,
},
}));
});
/**
* Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection
* @returns Initialized AirDC++ connection socket instance
*/
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
try {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
setState({
airDCPPSessionInformation,
});
// Set up event listeners
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
console.log(downloadProgressData);
setState({
airDCPPDownloadTick: downloadProgressData,
});
},
);
initializedAirDCPPSocket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
count += 1;
}
}
},
);
return initializedAirDCPPSocket;
} catch (error) {
console.error(error);
}
};
// 1. get settings from mongo
const { data } = await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
});
const directConnectConfiguration = data?.directConnect?.client.host;
// 2. If available, init AirDC++ Socket with those settings
if (!isNil(directConnectConfiguration)) {
const airDCPPSocketInstance = await initializeAirDCPPSocket(
directConnectConfiguration,
);
setState({
airDCPPSocketInstance,
airDCPPClientConfiguration: directConnectConfiguration,
});
} else {
console.log("problem");
}

1044
yarn.lock

File diff suppressed because it is too large Load Diff