Compare commits

..

35 Commits

Author SHA1 Message Date
d438eb7069 📆 Wired up the datepicker to LoCG pull list 2024-02-06 05:55:45 -05:00
5873721308 🏗️ Continued refactoring of PullList, Volumes etc. 2024-02-04 21:58:15 -05:00
d8a45408cb 🏗️ Abstracted heading/subheading into Header 2024-01-30 06:09:17 -05:00
a3b1e68b06 🛝 Added keen-slider for pull list 2024-01-29 00:34:29 -05:00
6081b817e4 🏗️ Cleaning up useless files 2024-01-28 11:21:08 -05:00
ada803d3cb 🏗️ Refactoring Volume groups and wanted panel 2024-01-24 18:14:49 -05:00
c86d0d8b15 🔍 Fixing CV search page 2024-01-19 17:14:22 -05:00
c25dc40dac 🏗️ Fixed volume group card stacks on Dashboard 2024-01-15 22:52:35 -05:00
a2fe633502 🏗️ Fixed CV-sourced Volume info panel 2024-01-15 01:08:36 -05:00
3ac357e46a 🏗️ Fixed an invalidation query on DC++ download panel 2024-01-12 22:49:20 -05:00
7e7ccff1a1 🏗️ Fixed invalidation of archiveOps 2024-01-11 16:47:11 -05:00
9884da06ef 🏗️ Settings styling tweaks 2024-01-10 21:58:50 -05:00
b75862398d 🔎 Added a check for existing uncompressed archives 2024-01-08 17:29:57 -05:00
a2005fcadb 🔧 Started work on Edit Metadata panel 2024-01-08 15:45:10 -05:00
3b11b648c6 🔧 Fixed # symbol handling in URLs 2024-01-08 13:57:12 -05:00
f3abc07005 🏗️ Fix for encoding # in URIs 2024-01-08 13:45:05 -05:00
4e0e1068fa 🤐 Added a uncompress indicator 2024-01-07 22:12:47 -05:00
09151a99e9 🏗️ Refactored the search form 2024-01-04 09:24:56 -05:00
ad12c05514 🖌️ Cleaned up the form 2024-01-04 09:17:35 -05:00
4b40f7e9c2 🖌️ Styling tweaks to the side panel 2024-01-04 09:03:01 -05:00
9bbb066c38 🤼 Revamped CV match panel UX 2024-01-04 00:39:29 -05:00
d2873893b8 🏗️ Refactored the scored matches 2024-01-02 16:01:54 -05:00
e00e8c17d8 🤼 Cleaning up CV match panel 2023-12-31 23:55:15 -05:00
0ade3f9354 🏗️ Refactored the action menu 2023-12-31 18:16:07 -05:00
b11cd76e37 🧩 CV Match panel refactor WIP 2023-12-30 10:19:24 -05:00
6526e46edc 🃏 Styling the action menu 2023-12-30 09:19:48 -05:00
a33ebf542f 🔧 Tweaked Archive ops further 2023-12-30 01:31:04 -05:00
c8bdb54066 🏗 Fixed the archive ops panel 2023-12-30 00:50:28 -05:00
a25837e2aa 🔧 Fixing table styes 2023-12-29 23:37:07 -05:00
4b2f905dc5 🏗️ Changes to ComicDetail section 2023-12-29 22:24:37 -05:00
9db4bc239e 🏗️ Wired up search with RQ 2023-12-28 22:50:32 -05:00
63ab0784e3 🪑 Many changes to DC++ downloads table 2023-12-27 23:45:48 -05:00
d647feff4d 🪑 Cleaned up the DC++ search results table 2023-12-26 00:51:53 -05:00
8f42afe560 🪑 Styled download panel table 2023-12-25 00:27:35 -05:00
432fe58585 🗂️ Added tab icons and styles 2023-12-21 22:04:06 -05:00
69 changed files with 3570 additions and 4058 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

@@ -6,25 +6,14 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots ### Screenshots
#### Dashboard ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg) ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png)
#### Issue View ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg) ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel ### 🦄 Early Development Support Channel
@@ -39,8 +28,7 @@ ThreeTwo! currently is set up as:
1. The UI, this repo. 1. The UI, this repo.
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service) 2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service) 3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service) 4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
## Docker Instructions ## Docker Instructions
@@ -55,18 +43,20 @@ For debugging and troubleshooting, you can run this app locally using these step
3. This will open `http://localhost:5173` in your default browser 3. This will open `http://localhost:5173` in your default browser
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work. 4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## Troubleshooting
## Troubleshooting
### Docker ### Docker
1. `docker-compose up` is taking a long time 1. `docker-compose up` is taking a long time
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading. This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
2. What folder do my comics go in? 2. What folder do my comics go in?
Your comics go in the `comics` directory at the root of this project. Your comics go in the `comics` directory at the root of this project.
## Contribution Guidelines ## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md) See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

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="column is-three-fifths">
role="alert" <article className="message is-info">
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="message-body is-size-6 is-family-secondary">
> 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

@@ -1,62 +0,0 @@
import React from "react";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { map } from "lodash";
import { DownloadProgressTick } from "./DownloadProgressTick";
export const AirDCPPBundles = (props) => {
return (
<div className="overflow-x-auto w-fit mt-6">
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
<thead>
<tr className="border-b border-gray-300 dark:border-slate-700">
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Filename
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Download Status
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Bundle ID
</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle, index) => (
<tr
key={bundle.id}
className={
Number(index) !== props.data.length - 1
? "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 className="px-3 py-2 align-top">
{prettyBytes(bundle.size)}
</td>
<td className="px-3 py-2 align-top">
<DownloadProgressTick bundleId={bundle.id} />
</td>
<td className="px-3 py-2 align-top">
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium">
{bundle.id}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

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

@@ -12,12 +12,12 @@ import { Menu } from "./ActionMenu/Menu";
import { ArchiveOperations } from "./Tabs/ArchiveOperations"; import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { ComicInfoXML } from "./Tabs/ComicInfoXML"; import { ComicInfoXML } from "./Tabs/ComicInfoXML";
import AcquisitionPanel from "./AcquisitionPanel"; import AcquisitionPanel from "./AcquisitionPanel";
import TorrentSearchPanel from "./TorrentSearchPanel";
import DownloadsPanel from "./DownloadsPanel"; import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation"; 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 +33,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 +67,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 +84,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 +95,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 +105,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 +130,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 +152,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 +162,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 +173,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 +193,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 +231,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 +245,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 +271,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 +336,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}
/> />
), ),
@@ -377,18 +350,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
</span> </span>
), ),
name: "Torrent Search", name: "Torrent Search",
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />, content: <>Torrents</>,
shouldShow: true, shouldShow: true,
}, },
{ {
id: 6, id: 6,
name: "Downloads", name: "Downloads",
icon: ( icon: <>{acquisition?.directconnect?.downloads?.length}</>,
<>
{(acquisition?.directconnect?.downloads?.length || 0) +
(acquisition?.torrent?.length || 0)}
</>
),
content: content:
!isNil(data.data) && !isEmpty(data.data) ? ( !isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} /> <DownloadsPanel key={5} />
@@ -423,12 +391,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 +445,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,5 +1,7 @@
import React, { ReactElement } from "react"; import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail"; import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";

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">
@@ -25,7 +16,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
<div className="min-w-fit"> <div className="min-w-fit">
<Card <Card
imageUrl={data.volumeInformation.image.thumb_url} imageUrl={data.volumeInformation.image.thumb_url}
orientation={"cover-only"} orientation={"vertical-2"}
hasDetails={false} hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }} // cardContainerStyle={{ maxWidth: 200 }}
/> />
@@ -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,154 +1,119 @@
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 { AirDCPPBundles } from "./AirDCPPBundles"; import { isEmpty, map } from "lodash";
import { TorrentDownloads } from "./TorrentDownloads"; import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store"; 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 [infoHashes, setInfoHashes] = useState<string[]>([]); const [bundles, setBundles] = useState([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]); const { airDCPPSocketInstance } = useStore(
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">( useShallow((state) => ({
"directconnect", airDCPPSocketInstance: state.airDCPPSocketInstance,
})),
); );
const { socketIOInstance } = useStore( // Fetch the downloaded files and currently-downloading file(s) from AirDC++
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })), const { data: comicObject, isSuccess } = useQuery({
); queryKey: ["bundles"],
/**
* Registers socket listeners on mount and cleans up on unmount.
*/
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 };
return next;
});
};
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
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",
});
// 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]);
const Bundles = (props) => {
return (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead>
<tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Filename
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Size
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Time
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{map(props.data, (bundle) => (
<tr key={bundle.id} className="text-sm">
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="text-xs">
{ellipsize(bundle.target, 88)}
</span>
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{prettyBytes(bundle.size)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
return ( return (
<> <div className="columns is-multiline">
<div className="mt-5 mb-3"> {!isEmpty(airDCPPSocketInstance) && !isEmpty(bundles) && (
<nav className="flex space-x-2"> <Bundles data={bundles} />
<button )}
onClick={() => setActiveTab("directconnect")} </div>
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">
{activeTab === "torrents" ? (
<TorrentDownloads data={torrentDetails} />
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
<AirDCPPBundles data={bundles.data} />
) : (
<p>No DC++ bundles found.</p>
)}
</div>
</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="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>
</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

@@ -1,9 +1,15 @@
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useEffect, useState } from "react";
import { isNil } from "lodash"; import { isNil } from "lodash";
export const TabControls = (props): ReactElement => { export const TabControls = (props): ReactElement => {
// const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, downloadCount } = props; const { filteredTabs, downloadCount } = props;
const [active, setActive] = useState(filteredTabs[0].id); const [active, setActive] = useState(filteredTabs[0].id);
// useEffect(() => {
// setActive(filteredTabs[0].id);
// }, [filteredTabs]);
return ( return (
<> <>
@@ -13,11 +19,7 @@ export const TabControls = (props): ReactElement => {
{filteredTabs.map(({ id, name, icon }) => ( {filteredTabs.map(({ id, name, icon }) => (
<a <a
key={id} key={id}
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 ${ className="inline-flex shrink-0 items-center gap-2 border-b border-transparent px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:text-gray-700"
active === id
? "border-b border-cyan-50 dark:text-slate-200"
: "border-b border-transparent"
}`}
aria-current="page" aria-current="page"
onClick={() => setActive(id)} onClick={() => setActive(id)}
> >
@@ -26,7 +28,7 @@ export const TabControls = (props): ReactElement => {
{id === 6 && !isNil(downloadCount) ? ( {id === 6 && !isNil(downloadCount) ? (
<span className="inline-flex flex-row"> <span className="inline-flex flex-row">
{/* download count */} {/* download count */}
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400"> <span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="text-md text-slate-500 dark:text-slate-900"> <span className="text-md text-slate-500 dark:text-slate-900">
{icon} {icon}
</span> </span>

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,19 @@ 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 constructImagePaths = (data): Array<string> => {
useState(false);
const constructImagePaths = (data: string[]): Array<string> => {
return data?.map((path: string) => return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)), escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
); );
}; };
// Listen to the uncompression complete event and orchestrate the final payload // Listen to the uncompression complete event and orchestrate the final payload
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,14 +56,13 @@ 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);
if (isMounted) { if (isMounted) {
setUncompressedArchive(uncompressedArchive); setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
} }
}, },
}); });
@@ -140,13 +122,12 @@ export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement =
enabled: false, enabled: false,
}); });
if (isSuccess && shouldRefetchComicBookData) { if (isSuccess) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] }); queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
} }
// sliding panel init // sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = { const contentForSlidingPanel = {
imageAnalysis: { imageAnalysis: {
content: () => { content: () => {
return ( return (
@@ -158,7 +139,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 +148,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);
@@ -190,8 +171,7 @@ export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement =
</div> </div>
</article> </article>
<div className="mt-5"> <div className="mt-5">
{data.rawFileDetails.archive?.uncompressed && {data.rawFileDetails.archive?.uncompressed ? (
!isEmpty(uncompressedArchive) ? (
<article <article
role="alert" role="alert"
className="mt-4 text-md 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" className="mt-4 text-md 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"
@@ -207,7 +187,7 @@ export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement =
) : null} ) : null}
<div className="flex flex-row gap-2 mt-4"> <div className="flex flex-row gap-2 mt-4">
{isEmpty(uncompressedArchive) ? ( {!data.rawFileDetails?.archive?.uncompressed ? (
<button <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-3 py-2 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:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()} onClick={() => refetch()}

View File

@@ -1,77 +0,0 @@
import React from "react";
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
export const TorrentDownloads = (props) => {
const { data } = props;
console.log(Object.values(data));
return (
<>
{data.map(({ torrent }) => {
return (
<dl className="mt-5 dark:text-slate-200 text-slate-600">
<dt className="text-lg">{torrent.name}</dt>
<p className="text-sm">{torrent.hash}</p>
<p className="text-sm">
Added on {dayjs.unix(torrent.added_on).format("ddd, D MMM, YYYY")}
</p>
<p className="flex gap-2 mt-1">
{torrent.progress > 0 ? (
<>
<progress
className="w-80 mt-2 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-slate-300 [&::-webkit-progress-value]:bg-green-400 [&::-moz-progress-bar]:bg-green-400 h-2"
value={Math.floor(torrent.progress * 100).toString()}
max="100"
></progress>
<span>{Math.floor(torrent.progress * 100)}%</span>
{/* downloaded/left */}
<p className="inline-flex items-center bg-slate-200 text-green-800 dark:text-green-900 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-slate-400">
<span className="pr-1">
<i className="icon-[solar--arrow-to-down-left-outline] h-4 w-4"></i>
</span>
<span className="text-md">
{prettyBytes(torrent.downloaded)}
</span>
{/* uploaded */}
<span className="pr-1 text-orange-800 dark:text-orange-900 ml-2">
<i className="icon-[solar--arrow-to-top-left-outline] h-4 w-4"></i>
</span>
<span className="text-md text-orange-800 dark:text-orange-900">
{prettyBytes(torrent.uploaded)}
</span>
</p>
</>
) : null}
</p>
<div className="flex gap-4 mt-2">
{/* Peers */}
<span className="inline-flex items-center bg-slate-200 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">
<i className="icon-[solar--station-minimalistic-line-duotone] h-5 w-5"></i>
</span>
<span className="text-md text-slate-900">
{torrent.trackers_count}
</span>
</span>
{/* Size */}
<span className="inline-flex items-center bg-slate-200 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--mirror-right-bold-duotone] h-4 w-4"></i>
</span>
<span className="text-md text-slate-900">
{prettyBytes(torrent.total_size)}
</span>
</span>
</div>
</dl>
);
})}
</>
);
};
export default TorrentDownloads;

View File

@@ -1,202 +0,0 @@
import React, { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Form, Field } from "react-final-form";
import {
PROWLARR_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { isEmpty, isNil } from "lodash";
import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes";
export const TorrentSearchPanel = (props) => {
const { issueName, comicObjectId } = props;
// Initialize searchTerm with issueName from props
const [searchTerm, setSearchTerm] = useState({ issueName });
const [torrentToDownload, setTorrentToDownload] = useState("");
const { data, isSuccess, isLoading } = useQuery({
queryKey: ["searchResults", searchTerm.issueName],
queryFn: async () => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
method: "POST",
data: {
prowlarrQuery: {
port: "9696",
apiKey: "38c2656e8f5d4790962037b8c4416a8f",
offset: 0,
categories: [7030],
query: searchTerm.issueName,
host: "localhost",
limit: 100,
type: "search",
indexerIds: [2],
},
},
});
},
enabled: !isNil(searchTerm.issueName) && searchTerm.issueName.trim() !== "", // Make sure searchTerm is not empty
});
const mutation = useMutation({
mutationFn: async (newTorrent) =>
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
onSuccess: async (data) => {
console.log(data);
},
});
const searchIndexer = (values) => {
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
};
const downloadTorrent = (evt) => {
const newTorrent = {
comicObjectId,
torrentToDownload: evt,
};
mutation.mutate(newTorrent);
};
return (
<>
<div className="mt-5">
<Form
onSubmit={searchIndexer}
initialValues={searchTerm}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
{/* Icon placeholder */}
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<input
{...input}
type="text"
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="Enter a search term"
/>
<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"
type="submit"
>
<div className="flex flex-row">
Search Indexer
<div className="h-5 w-5 ml-1">
<i className="h-6 w-6 icon-[solar--magnet-bold-duotone]" />
</div>
</div>
</button>
</div>
</div>
)}
</Field>
</form>
)}
/>
</div>
<article
role="alert"
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"
>
<div>
The default search term is an auto-detected title; you may need to
change it to get better matches if the auto-detected one doesn't work.
</div>
</article>
{!isEmpty(data?.data) ? (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
<thead>
<tr>
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
Name
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Indexer
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{data?.data.map((result, idx) => (
<tr key={idx}>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
<p>{ellipsize(result.fileName, 90)}</p>
{/* Seeders/Leechers */}
<div className="flex gap-3 mt-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--archive-up-minimlistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.seeders} seeders
</span>
</span>
<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--archive-down-minimlistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.leechers} leechers
</span>
</span>
{/* Size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(result.size)}
</span>
</span>
{/* Files */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.files} files
</span>
</span>
</div>
</td>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
{result.indexer}
</td>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-sm">
<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-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => downloadTorrent(result.downloadUrl)}
>
<span className="text-xs">Download</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
);
};
export default TorrentSearchPanel;

View File

@@ -5,6 +5,7 @@ import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups"; import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics"; import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList"; import { PullList } from "./PullList";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
@@ -21,15 +22,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 +40,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"],
@@ -57,23 +54,16 @@ export const Dashboard = (): ReactElement => {
queryKey: ["volumeGroups"], queryKey: ["volumeGroups"],
}); });
const { data: statistics } = useQuery({ //
queryFn: async () => // const libraryStatistics = useSelector(
await axios({ // (state: RootState) => state.comicInfo.libraryStatistics,
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`, // );
method: "GET",
}),
queryKey: ["libraryStatistics"],
});
return ( return (
<div className="container mx-auto max-w-full"> <div className="container mx-auto max-w-full">
<PullList /> <PullList />
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />} {recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
{/* Wanted comics */} {/* Wanted comics */}
<WantedComicsList comics={wantedComics?.data?.docs} /> <WantedComicsList comics={wantedComics?.data?.docs} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics?.data} />}
{/* Volume groups */} {/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups?.data} /> <VolumeGroups volumeGroups={volumeGroups?.data} />
</div> </div>

View File

@@ -1,99 +1,113 @@
import React, { ReactElement, useEffect } from "react"; import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash"; import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
export const LibraryStatistics = ( export const LibraryStatistics = (
props: ILibraryStatisticsProps, props: ILibraryStatisticsProps,
): ReactElement => { ): ReactElement => {
const { stats } = props; // const { stats } = props;
return ( return (
<div className="mt-5"> <div className="mt-5">
<Header <h4 className="title is-4">
headerContent="Your Library In Numbers" <i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
subHeaderContent={ </h4>
<span className="text-md">A brief snapshot of your library.</span> <p className="subtitle is-7">A brief snapshot of your library.</p>
} <div className="columns is-multiline">
iconClassNames="fa-solid fa-binoculars mr-2" <div className="column is-narrow is-two-quarter">
/> <dl className="box">
<dd className="is-size-4">
<div className="mt-3"> <span className="has-text-weight-bold">
<div className="flex flex-row gap-5"> {props.stats.totalDocuments}
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center"> </span>{" "}
<dt className="text-lg font-medium text-gray-500">Library size</dt> files
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</dd> </dd>
<dd> <dd className="is-size-4">
<span className="text-2xl text-green-600"> Library size
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize && {props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)} prettyBytes(props.stats.comicDirectorySize)}
</span> </span>
</dd> </dd>
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && ( !isEmpty(props.stats.statistics[0].issues) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"> <dd className="is-size-6">
<span className="text-xl"> <span className="has-text-weight-bold">
{props.stats.statistics[0].issues.length} {props.stats.statistics[0].issues.length}
</span>{" "} </span>{" "}
tagged with ComicVine tagged with ComicVine
</div> </dd>
)} )}
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && ( !isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"> <dd className="is-size-6">
<span className="text-xl"> <span className="has-text-weight-bold">
{props.stats.statistics[0].issuesWithComicInfoXML.length} {props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "} </span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1"> <span className="tag is-warning has-text-weight-bold mr-2 ml-1">
with ComicInfo.xml ComicInfo.xml
</span> </span>
</div> </dd>
)} )}
</div> </dl>
</div>
<div className=""> <div className="p-3 column is-one-quarter">
{!isUndefined(props.stats.statistics) && <dl className="box">
!isEmpty(props.stats.statistics[0].fileTypes) && <dd className="is-size-6">
map(props.stats.statistics[0].fileTypes, (fileType, idx) => { <span className="has-text-weight-bold"></span> Issues
return ( </dd>
<span <dd className="is-size-6">
key={idx} <span className="has-text-weight-bold">304</span> Volumes
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center" </dd>
> <dd className="is-size-6">
{fileType.data.length} {fileType._id} {!isUndefined(props.stats.statistics) &&
</span> !isEmpty(props.stats.statistics[0].fileTypes) &&
); map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
})} return (
</div> <span key={idx}>
<span className="has-text-weight-bold">
{fileType.data.length}
</span>
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
{fileType._id}
</span>
</span>
);
})}
</dd>
</dl>
</div>
{/* file types */} {/* file types */}
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3"> <div className="p-3 column is-two-fifths">
{/* publisher with most issues */} {/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty( !isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0], props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && ( ) && (
<> <dd className="is-size-6">
<span className=""> <span className="has-text-weight-bold">
{ {
props.stats.statistics[0] props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id .publisherWithMostComicsInLibrary[0]._id
} }
</span> </span>
{" has the most issues "} {" has the most issues "}
<span className=""> <span className="has-text-weight-bold">
{ {
props.stats.statistics[0] props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count .publisherWithMostComicsInLibrary[0].count
} }
</span> </span>
</> </dd>
)} )}
</div> <dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
</dl>
</div> </div>
</div> </div>
</div> </div>

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,66 @@ export const PullList = (): ReactElement => {
return ( return (
<> <>
<div className="content"> <div className="content">
<div className="mx-auto"> <Header
<Header headerContent="Discover"
headerContent="Discover" subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
subHeaderContent={ iconClassNames="fa-solid fa-binoculars mr-2"
<span className="text-md"> link="/pull-list/all/"
Pull List aggregated for the week from{" "} />
<span className="underline"> <div className="flex flex-row gap-5 mb-3">
<a href="https://leagueofcomicgeeks.com"> {/* select week */}
League Of Comic Geeks <div className="flex flex-row gap-4 my-3">
</a> <Form
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" /> onSubmit={() => {}}
</span> render={({ handleSubmit }) => (
</span> <form>
} <div className="flex flex-col gap-2">
iconClassNames="fa-solid fa-binoculars mr-2" {/* week selection for pull list */}
link="/pull-list/all/" <DatePickerDialog
/> inputValue={inputValue}
<div className="flex flex-row gap-5 mb-3"> setter={setInputValue}
{/* select week */} />
<div className="flex flex-row gap-4 my-3"> {inputValue && (
<Form <div className="text-sm">
onSubmit={() => {}} Showing pull list for <span>{inputValue}</span>
render={({ handleSubmit }) => ( </div>
<form> )}
<div className="flex flex-col gap-2"> </div>
{/* week selection for pull list */} </form>
<DatePickerDialog )}
inputValue={inputValue} />
setter={setInputValue}
/>
{inputValue && (
<div className="text-sm">
Showing pull list for{" "}
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{inputValue}
</span>
</div>
)}
</div>
</form>
)}
/>
</div>
</div> </div>
</div> </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.downloads.length}
</span>
</span>
</div> </div>
), ),
}, },

View File

@@ -1,16 +1,15 @@
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 { importToDB } from "../../actions/fileops.actions";
import { comicinfoAPICall } from "../../actions/comicinfo.actions";
import { search } from "../../services/api/SearchApi";
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 +22,65 @@ 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) => { // queryClient.invalidateQueries({ queryKey: ["comicvineSearchResults"] });
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 +90,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 +110,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 +142,77 @@ 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 && ( {!isNil(comicVineSearchResults?.data.results) &&
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> !isEmpty(comicVineSearchResults?.data.results) ? (
Loading results... <div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
</div>
)}
{!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

@@ -1,62 +0,0 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Form, Field } from "react-final-form";
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
import axios from "axios";
export const ProwlarrSettingsForm = (props) => {
const { data } = useQuery({
queryFn: async (): any => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
method: "POST",
data: {
host: "localhost",
port: "9696",
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
},
});
},
queryKey: ["prowlarrConnectionResult"],
});
console.log(data);
const submitHandler = () => {};
const initialData = {};
return (
<>
Prowlarr Settings.
<Form
onSubmit={submitHandler}
initialValues={initialData}
render={({ handleSubmit }) => (
<form>
<article
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"
>
<div>
<p>Configure Prowlarr integration here.</p>
<p>
Note that you need a Prowlarr instance hosted and running to
configure the integration.
</p>
<p>
See{" "}
<a
className="underline"
href="http://airdcpp.net/docs/installation/installation.html"
>
here
</a>{" "}
for Prowlarr installation instructions for various platforms.
</p>
</div>
</article>
</form>
)}
/>
</>
);
};
export default ProwlarrSettingsForm;

View File

@@ -16,7 +16,16 @@ export const QbittorrentConnectionForm = (): ReactElement => {
}); });
const hostDetails = data?.data?.bittorrent?.client?.host; const hostDetails = data?.data?.bittorrent?.client?.host;
// connect to qbittorrent client // connect to qbittorrent client
const { data: connectionDetails } = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/connect",
method: "POST",
data: hostDetails,
}),
enabled: !!hostDetails,
});
// get qbittorrent client info // get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({ const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"], queryKey: ["qbittorrentClientInfo"],
@@ -25,6 +34,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
url: "http://localhost:3060/api/qbittorrent/getClientInfo", url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET", method: "GET",
}), }),
enabled: !!connectionDetails,
}); });
// Update action using a mutation // Update action using a mutation
const { mutate } = useMutation({ const { mutate } = useMutation({

View File

@@ -3,8 +3,6 @@ import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm"; 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 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 +11,131 @@ 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: "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,36 @@
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,
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 +39,10 @@ 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(); const row = value.getValue()._source;
console.log("Comic row data:", row); return row && <MetadataPanel data={row} />;
return row ? <MetadataPanel data={row} /> : null;
}, },
}, },
], ],
@@ -105,73 +53,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 ? (
<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,72 @@
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) => {
console.log(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 +76,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 +102,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 +116,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

@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
type IHeaderProps = { type IHeaderProps = {
headerContent: string; headerContent: string;
subHeaderContent: ReactElement; subHeaderContent: string;
iconClassNames: string; iconClassNames: string;
link?: string; link?: string;
}; };

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,27 @@ 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 => {
/** console.log(props);
* 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 +44,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 +95,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 +148,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 +174,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

@@ -90,24 +90,3 @@ export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
port: "3060", port: "3060",
apiPath: `/api/qbittorrent`, apiPath: `/api/qbittorrent`,
}); });
export const PROWLARR_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/prowlarr`,
});
export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
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"
} }
] ]
}, },
@@ -57,7 +57,7 @@
"displayName": "Prowlarr", "displayName": "Prowlarr",
"children": [ "children": [
{ {
"id": "prwlr-connection", "id": "prowlarr-connection",
"displayName": "Connection" "displayName": "Connection"
}, },
{ {

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,15 +7,14 @@ 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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import"; import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard"; import Dashboard from "./components/Dashboard/Dashboard";
import Search from "./components/Search/Search"; 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 +38,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 /> },
], ],
@@ -49,5 +47,6 @@ const router = createBrowserRouter([
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>, </QueryClientProvider>,
); );

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");
}

1050
yarn.lock

File diff suppressed because it is too large Load Diff