Compare commits

..

32 Commits

Author SHA1 Message Date
7739d1ced7 Merge branch 'main' into dark-mode-refactor 2023-12-21 16:21:34 -05:00
0d438ab913 Removing yarn.lockfile 2023-12-21 16:16:31 -05:00
a7b9138f52 🏗️ Fixed fonts, and comic detail page first draft 2023-12-21 15:19:28 -05:00
0872aa4d8a 🏗️ Cleaned the table further 2023-12-20 00:08:16 -05:00
4d1d29a361 🏗️ Added icons and details to metadata 2023-12-18 23:31:43 -05:00
b1d8951842 🏗️ Refactoring table for library 2023-12-18 18:02:15 -05:00
1180a2b42c 🧹 Formatting in connection confirmation panels 2023-12-16 22:39:29 -05:00
5ffcb0d642 🪑 Refactoring the table 2023-12-16 22:02:56 -05:00
f293d9e3ba 🔠 Added a monospace font 2023-12-15 18:13:25 -05:00
1f6d6ca6b6 🏗️ Form refactor 2023-12-14 22:25:48 -05:00
a7508df0b3 🔧 Styling form inputs 2023-12-13 21:43:21 -05:00
81b590157e 🏗️ Cleaning up forms and cards 2023-12-13 12:30:14 -05:00
72a308801d 🏗️ Added a horizontal medium variant 2023-12-11 14:14:06 -05:00
e1be413ff6 🏗️ Building variants for Cards 2023-12-11 10:51:47 -05:00
435056048b 🏗️ Refactored the card grid on dashboard 2023-12-08 18:16:28 -05:00
026d5832c5 🏗️ Refactored Import socket events 2023-12-06 21:56:58 -06:00
a068b82db3 🏗️ Cleaned up past imports table 2023-12-06 10:58:22 -06:00
c74c443534 🪑 Cleaning up the table styles 2023-12-05 21:40:40 -06:00
701d216e44 🏗️ Building out the import page 2023-12-05 19:23:56 -06:00
4034c4e2b4 🌜 Added Dark mode to the body 2023-12-05 05:24:33 -05:00
9c4f2ae9b0 🏗️ Wiring up the dark mode toggle 2023-12-05 04:57:25 -05:00
a07c0357f0 🔧 Added code for dark mode toggle 2023-12-04 16:41:41 -05:00
4215470871 🔧 Added solar icons 2023-12-04 16:10:00 -05:00
38599fbc78 🖼️ Added solar icons 2023-12-04 12:31:31 -05:00
7f3a1caa3c 🏗️ Migrating Navbar to TailwindCSS 2023-12-04 11:12:44 -05:00
d36138c800 Update App.scss 2023-12-03 22:46:49 -05:00
1ed6a622d4 🌜 Trying dark mode on the react-select 2023-12-03 16:02:54 -05:00
29e0772a10 🌜 Initial Dark Mode support 2023-12-03 15:28:05 -05:00
57b713aca1 🏗️ Refactored the AirDC++ download panel 2023-12-02 11:38:17 -05:00
dfd99e45b6 🔧 Implementing download method 2023-11-29 23:24:34 -05:00
591ecb394c 🔧 Formatted the search query box 2023-11-29 23:08:00 -05:00
145427d3fd 🏗️ Acquisition Panel refactor WIP 2023-11-29 21:22:48 -05:00
84 changed files with 4444 additions and 16036 deletions

1
.gitignore vendored
View File

@@ -4,7 +4,6 @@ comics/
docs/ docs/
userdata/ userdata/
dist/ dist/
storybook-static/*
src/client/assets/scss/App.css src/client/assets/scss/App.css
/server/ /server/
node_modules/ node_modules/

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

@@ -1,7 +1,7 @@
{ {
"name": "threetwo", "name": "threetwo",
"version": "0.1.0", "version": "0.0.2",
"description": "ThreeTwo! A good comic book curator.", "description": "ThreeTwo! A comic book curator.",
"main": "server/index.js", "main": "server/index.js",
"typings": "server/index.js", "typings": "server/index.js",
"scripts": { "scripts": {
@@ -18,10 +18,8 @@
"@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", "@redux-devtools/extension": "^3.2.5",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5", "@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
@@ -29,54 +27,49 @@
"@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",
"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",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"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.6.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"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-masonry-css": "^1.0.16",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-router": "^7.5.2", "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-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"react-toastify": "^10.0.5", "reapop": "^4.2.1",
"slick-carousel": "^1.8.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,13 +117,10 @@
"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.3.5",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6" "typescript": "^5.1.6"
},
"resolutions": {
"jackspeak": "2.1.1"
} }
} }

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,94 @@
import React, { ReactElement } from "react"; import { filter, isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select"; import React, { ReactElement, useCallback } from "react";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
export const Menu = (props: any): ReactElement => { export const Menu = (props): ReactElement => {
const { const { data } = props;
filteredActionOptions, const { setSlidingPanelContentId, setVisible } = props.handlers;
customStyles, const openDrawerWithCVMatches = useCallback(() => {
handleActionSelection, let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
Placeholder, let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
} = props.configuration;
if (!isUndefined(data.rawFileDetails)) {
issueSearchQuery = refineQuery(data.rawFileDetails.name);
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
option: (base, { data, isDisabled, isFocused, isSelected }) => {
return {
...base,
backgroundColor: isFocused ? "gray" : "black",
};
},
control: (base) => ({
...base,
backgroundColor: "black",
border: "1px solid #CCC",
}),
};
return ( return (
<Select <Select
components={{ Placeholder }} components={{ Placeholder }}
placeholder={ placeholder={
<span className="inline-flex flex-row items-center gap-1.5 pt-1"> <span>
<div className="w-4 h-4"> <i className="fa-solid fa-list"></i> Actions
<i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">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,11 @@ 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 } from "lodash";
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";
@@ -28,39 +27,8 @@ import ComicViewer from "react-comic-viewer";
import { extractComicArchive } from "../../actions/fileops.actions"; import { extractComicArchive } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import axios from "axios";
import { styled } from "styled-components";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
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.
* *
@@ -88,13 +56,25 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false); const [modalIsOpen, setIsOpen] = useState(false);
const [comicVineMatches, setComicVineMatches] = useState([]);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
// 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, {
@@ -107,11 +87,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// ); // );
}, []); }, []);
// overridden <SlidingPanel> with some styles const afterOpenModal = useCallback((things) => {
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
const afterOpenModal = useCallback((things: any) => {
// references are now sync'd and can be accessed. // references are now sync'd and can be accessed.
// subtitle.style.color = "#f00"; // subtitle.style.color = "#f00";
console.log("kolaveri", things); console.log("kolaveri", things);
@@ -122,187 +98,61 @@ 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 className="card search-criteria-card">
<ComicVineSearchForm data={rawFileDetails} /> <div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
</div> </div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
<div className="border-slate-500 border rounded-lg p-2 mt-3"> {inferredMetadata.issue ? (
<p className="">Searching for:</p> <div className="ml-3">
{inferredMetadata?.issue ? ( <span className="tag mr-3">{inferredMetadata.issue.name} </span>
<> <span className="tag"> # {inferredMetadata.issue.number} </span>
<span className="">{inferredMetadata.issue.name} </span> </div>
<span className=""> # {inferredMetadata.issue.number} </span> ) : null}
</> {!comicVineAPICallProgress ? (
) : null} <ComicVineMatchPanel
</div> props={{
<ComicVineMatchPanel comicVineSearchQueryObject,
props={{ comicVineAPICallProgress,
comicVineMatches, comicVineSearchResults,
comicObjectId, comicObjectId,
}} }}
/> />
) : (
<div className="progress-indicator-container">
<div className="indicator">
<Loader
type="MutatingDots"
color="#CCC"
secondaryColor="#999"
height={100}
width={100}
visible={comicVineAPICallProgress}
/>
</div>
</div>
)} */}
</> </>
), ),
}, },
editComicBookMetadata: { editComicBookMetadata: {
content: () => <EditMetadataPanel data={rawFileDetails} />, content: () => <EditMetadataPanel />,
}, },
}; };
// Actions
const fetchComicVineMatches = async (
searchPayload: any,
issueSearchQuery: any,
seriesSearchQuery: any,
) => {
try {
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
method: "POST",
data: {
format: "json",
// hack
query: issueSearchQuery.inferredIssueDetails.name
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim(),
limit: "100",
page: 1,
resources: "volume",
scorerConfiguration: {
searchParams: issueSearchQuery.inferredIssueDetails,
},
rawFileDetails: searchPayload,
},
transformResponse: (r: string) => {
const matches = JSON.parse(r);
return matches;
// return sortBy(matches, (match) => -match.score);
},
});
let matches: any = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match: any) => match);
}
const scoredMatches = matches.sort((a: any, b: any) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
console.log(err);
}
};
// Action event handlers
const openDrawerWithCVMatches = () => {
let seriesSearchQuery: any = {};
let issueSearchQuery: any = {};
if (!isUndefined(rawFileDetails)) {
issueSearchQuery = refineQuery((rawFileDetails as any).name);
} else if (!isEmpty(comicvine)) {
issueSearchQuery = refineQuery((comicvine as any).name);
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Match on ComicVine</div>
</span>
);
const editLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Edit Metadata</div>
</span>
);
const deleteLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Delete Comic</div>
</span>
);
const Placeholder = (props: any) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action: any) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
menu: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base: any) => ({
...base,
color: "black",
}),
option: (base: any, { data, isDisabled, isFocused, isSelected }: any) => ({
...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}),
singleValue: (base: any) => ({
...base,
paddingTop: "0.4rem",
}),
control: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
color: "black",
border: "1px solid rgb(156, 163, 175)",
}),
};
// 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 =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails); !isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
const { issueName, url } = determineCoverFile({ const { issueName, url } = determineCoverFile({
rawFileDetails, rawFileDetails,
@@ -322,9 +172,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 1, id: 1,
name: "Volume Information", name: "Volume Information",
icon: ( icon: <i className="fa-solid fa-layer-group"></i>,
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: isComicBookMetadataAvailable ? ( content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} /> <VolumeInformation data={data.data} key={1} />
) : null, ) : null,
@@ -333,37 +181,35 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 2, id: 2,
name: "ComicInfo.xml", name: "ComicInfo.xml",
icon: ( icon: <i className="fa-solid fa-code"></i>,
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: ( content: (
<div key={2}> <div className="columns" key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />} <div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div> </div>
), ),
shouldShow: !isEmpty(comicInfo), shouldShow: !isEmpty(comicInfo),
}, },
{ {
id: 3, id: 3,
icon: ( icon: <i className="fa-regular fa-file-archive"></i>,
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "Archive Operations", name: "Archive Operations",
content: <ArchiveOperations data={data.data} key={3} />, content: <></>,
/*
<ArchiveOperations data={data.data} key={3} /> */
shouldShow: areRawFileDetailsAvailable, shouldShow: areRawFileDetailsAvailable,
}, },
{ {
id: 4, id: 4,
icon: ( icon: <i className="fa-solid fa-circle-nodes"></i>,
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "DC++ Search", name: "DC++ Search",
content: ( content: (
<AcquisitionPanel <AcquisitionPanel
query={airDCPPQuery} query={airDCPPQuery}
comicObjectId={_id} comicObjectId={_id}
comicObject={data.data} comicObject={data.data}
settings={userSettings} userSettings={userSettings}
key={4} key={4}
/> />
), ),
@@ -371,37 +217,26 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, },
{ {
id: 5, id: 5,
icon: ( icon: <i className="fa-solid fa-droplet"></i>,
<span className="inline-flex flex-row">
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
</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", icon: null,
icon: ( name: !isEmpty(data.data) ? (
<> <span className="download-tab-name">Downloads</span>
{(acquisition?.directconnect?.downloads?.length || 0) + ) : (
(acquisition?.torrent?.length || 0)} "Downloads"
</> ),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
), ),
content:
!isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} />
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
),
shouldShow: true, shouldShow: true,
}, },
]; ];
@@ -423,12 +258,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={{
@@ -437,21 +271,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
created_at: createdAt, created_at: createdAt,
updated_at: updatedAt, updated_at: updatedAt,
}} }}
> />
{/* action dropdown */}
<div className="mt-1 flex flex-row gap-2 w-full">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
configuration={{
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
</div>
</RawFileDetails>
{/* <Modal {/* <Modal
style={{ content: { marginTop: "2rem" } }} style={{ content: { marginTop: "2rem" } }}
@@ -473,15 +293,22 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
</Modal> */} </Modal> */}
</div> </div>
)} )}
{/* action dropdown */}
{/* <div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div> */}
</div> </div>
</div> </div>
<TabControls <TabControls
filteredTabs={filteredTabs} filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length || 0} acquisition={acquisition}
/> />
<StyledSlidingPanel <SlidingPane
isOpen={visible} isOpen={visible}
onRequestClose={() => setVisible(false)} onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"} title={"Comic Vine Search Matches"}
@@ -489,7 +316,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
> >
{slidingPanelContentId !== "" && {slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()} contentForSlidingPanel[slidingPanelContentId].content()}
</StyledSlidingPanel> </SlidingPane>
</> </>
)} )}
</div> </div>

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";
@@ -12,7 +14,7 @@ export const ComicDetailContainer = (): ReactElement | null => {
isLoading, isLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: ["comicBookMetadata"], queryKey: [],
queryFn: async () => queryFn: async () =>
await axios({ await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`, url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
@@ -22,6 +24,11 @@ export const ComicDetailContainer = (): ReactElement | null => {
}, },
}), }),
}); });
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{ {
isError && <>Error</>; isError && <>Error</>;

View File

@@ -1,118 +1,119 @@
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 { isUndefined } from "lodash";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import { convert } from "html-to-text"; export const ComicVineDetails = (props): ReactElement => {
interface ComicVineDetailsProps {
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="column is-half">
<div className=""> <div className="comic-detail comicvine-metadata">
<div> <dl>
<div className="flex flex-row gap-4"> <dt>ComicVine Metadata</dt>
<div className="min-w-fit"> <dd className="is-size-7">
<Card Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
imageUrl={data.volumeInformation.image.thumb_url} </dd>
orientation={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
</div>
{/* Comicvine metadata */} <dd>
<div className="mt-2"> <div className="columns mt-2">
<div className="text-md">ComicVine Metadata</div> <div className="column is-2">
<div className="text-sm"> <Card
Last scraped on{" "} imageUrl={data.volumeInformation.image.thumb_url}
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")} orientation={"vertical"}
</div> hasDetails={false}
<div className="text-sm"> // cardContainerStyle={{ maxWidth: 200 }}
ComicVine Issue ID />
<span>{data.id}</span> </div>
</div> <div className="column is-10">
</div> <dl>
</div> <dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
{/* Publisher details */} </dt>
<div className="ml-8"> <dd>
Published by{" "} Is a part of{" "}
<span>{data.volumeInformation.publisher.name}</span> <span className="has-text-info">
<div> {data.volumeInformation.name}
Total issues in this volume{" "}
<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="text-md text-slate-900 dark:text-slate-900">
{data.volumeInformation.count_of_issues}
</span>
</span> </span>
</div> </dd>
<div>
{data.issue_number && ( <dd>
<div className=""> Published by
<span>Issue Number</span> <span className="has-text-weight-semibold">
<span>{data.issue_number}</span> {" "}
{data.volumeInformation.publisher.name}
</span>
</dd>
<dd>
Total issues in this volume:
{data.volumeInformation.count_of_issues}
</dd>
<dd>
<div className="field is-grouped mt-2">
{data.issue_number && (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Issue Number</span>
<span className="tag is-warning">
{data.issue_number}
</span>
</div>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Detected Type</span>
<span className="tag is-warning">
{
detectIssueTypes(
data.volumeInformation.description,
).displayName
}
</span>
</div>
</div>
) : data.resource_type ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Type</span>
<span className="tag is-warning">
{data.resource_type}
</span>
</div>
</div>
) : null}
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">
ComicVine Issue ID
</span>
<span className="tag is-success">{data.id}</span>
</div>
</div> </div>
)} </div>
{!isUndefined( </dd>
detectIssueTypes(data.volumeInformation.description), </dl>
) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
</span>
</div>
) : data.resource_type ? (
<div>
<span>Type</span>
<span>{data.resource_type}</span>
</div>
) : null}
</div>
</div>
</div>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div> </div>
</div> </div>
</div> </dd>
</div> </dl>
</div> </div>
</div> </div>
); );
}; };
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

@@ -2,41 +2,22 @@ import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm"; import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => { export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const { comicObjectId, comicVineMatches } = comicVineData.props; const {
const { comicvine } = useStore( comicObjectId,
useShallow((state) => ({ comicVineSearchQueryObject,
comicvine: state.comicvine, comicVineAPICallProgress,
})), comicVineSearchResults,
); } = comicVineData.props;
return ( return (
<> <>
<div> <div className="search-results-container">
{!isEmpty(comicVineMatches) ? ( {!isEmpty(comicVineSearchResults) && (
<MatchResult <MatchResult
matchData={comicVineMatches} matchData={comicVineSearchResults}
comicObjectId={comicObjectId} comicObjectId={comicObjectId}
/> />
) : (
<>
<article
role="alert"
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 text-sm"
>
<div>
<p>ComicVine match results are an approximation.</p>
<p>
Auto-matching is not available yet. If you see no results or
poor quality ones, you can override the search query
parameters to get better ones.
</p>
</div>
</article>
<div className="text-md my-5">{comicvine.scrapingStatus}</div>
</>
)} )}
</div> </div>
</> </>

View File

@@ -34,55 +34,83 @@ export const ComicVineSearchForm = (data) => {
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<span className="flex items-center"> <span className="field is-normal">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5"> <label className="label mb-2 is-size-5">Search Manually</label>
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span> </span>
<label className="block py-1">Issue Name</label> <div className="field is-horizontal">
<Field name="issueName"> <div className="field-body">
{(props) => ( <div className="field">
<input <Field name="issueName">
{...props.input} {(props) => (
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-full rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" <p className="control is-expanded has-icons-left">
placeholder="Type the issue name" <input
/> {...props.input}
)} className="input is-normal"
</Field> placeholder="Type the issue name"
<div className="flex flex-row gap-4"> />
<div> <span className="icon is-small is-left">
<label className="block py-1">Number</label> <i className="fas fa-journal-whills"></i>
<Field name="issueNumber"> </span>
{(props) => ( </p>
<input )}
{...props.input} </Field>
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-14 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" </div>
placeholder="#"
/>
)}
</Field>
</div>
<div>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
</div> </div>
</div>
<div className="flex justify-end mt-5"> <div className="field is-horizontal">
<button <div className="field-body">
type="submit" <div className="field">
className="flex h-10 sm:mt-3 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-red-600 focus:outline-none focus:ring active:text-indigo-500" <Field name="issueNumber">
> {(props) => (
Search <p className="control has-icons-left">
</button> <input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
</form> </form>

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,103 @@
import React, { useEffect, ReactElement, useState, useMemo } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { AirDCPPBundles } from "./AirDCPPBundles"; import { RootState } from "threetwo-ui-typings";
import { TorrentDownloads } from "./TorrentDownloads"; import { isEmpty, isNil, map } from "lodash";
import { useQuery } from "@tanstack/react-query"; import prettyBytes from "pretty-bytes";
import axios from "axios"; import dayjs from "dayjs";
import { import ellipsize from "ellipsize";
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
export interface TorrentDetails { interface IDownloadsPanelProps {
infoHash: string; data: any;
progress: number; comicObjectId: string;
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 // const bundles = useSelector((state: RootState) => {
* It also listens for real-time torrent updates via a WebSocket. // return state.airdcpp.bundles;
* // });
* @component //
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available. // // AirDCPP Socket initialization
*/ // const userSettings = useSelector((state: RootState) => state.settings.data);
export const DownloadsPanel = (): ReactElement | null => { // const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
"directconnect",
);
const { socketIOInstance } = useStore( const {
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })), airDCPPState: { socket, settings },
); } = airDCPPConfiguration;
/** // Fetch the downloaded files and currently-downloading file(s) from AirDC++
* Registers socket listeners on mount and cleans up on unmount.
*/
useEffect(() => { useEffect(() => {
if (!socketIOInstance) return; try {
if (!isEmpty(userSettings)) {
// dispatch(
// getBundlesForComic(props.comicObjectId, socket, {
// username: `${settings.directConnect.client.host.username}`,
// password: `${settings.directConnect.client.host.password}`,
// }),
// );
}
} catch (error) {
throw new Error(error);
}
}, [airDCPPConfiguration]);
/** const Bundles = (props) => {
* Handler for incoming torrent data events. return !isEmpty(props.data) ? (
* Merges new entries or updates existing ones by infoHash. <div className="column is-full">
* <table className="table is-striped">
* @param {TorrentDetails} data - Payload from the socket event. <thead>
*/ <tr>
const handleTorrentData = (data: TorrentDetails) => { <th>Filename</th>
setTorrentDetails((prev) => { <th>Size</th>
const idx = prev.findIndex((t) => t.infoHash === data.infoHash); <th>Download Time</th>
if (idx === -1) { <th>Bundle ID</th>
return [...prev, data]; </tr>
} </thead>
const next = [...prev]; <tbody>
next[idx] = { ...next[idx], ...data }; {map(props.data, (bundle) => (
return next; <tr key={bundle.id}>
}); <td>
}; <h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData); return !isNil(props.data) ? (
return () => {
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
};
}, [socketIOInstance]);
// ————— DC++ Bundles (via REST) —————
const { data: bundles } = useQuery({
queryKey: ["bundles", comicObjectId],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
method: "POST",
data: {
comicObjectId,
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
},
}),
});
// ————— Torrent Jobs (via REST) —————
const { data: rawJobs = [] } = useQuery<any[]>({
queryKey: ["torrents", comicObjectId],
queryFn: async () => {
const { data } = await axios.get(
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
{ params: { trigger: activeTab } },
);
return Array.isArray(data) ? data : [];
},
initialData: [],
enabled: activeTab === "torrents",
});
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
useEffect(() => {
if (activeTab !== "torrents") return;
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
}, [activeTab]);
return (
<> <>
<div className="mt-5 mb-3"> <div className="columns is-multiline">
<nav className="flex space-x-2"> {!isEmpty(socket) ? (
<button <Bundles data={bundles} />
onClick={() => setActiveTab("directconnect")} ) : (
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${ <div className="column is-three-fifths">
activeTab === "directconnect" <article className="message is-info">
? "bg-green-500 text-white" <div className="message-body is-size-6 is-family-secondary">
: "bg-gray-200 text-gray-700 hover:bg-gray-300" AirDC++ is not configured. Please configure it in{" "}
}`} <code>Settings</code>.
> </div>
DC++ </article>
</button> </div>
<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> </div>
</> </>
); ) : null;
}; };
export default DownloadsPanel; export default DownloadsPanel;

View File

@@ -9,8 +9,6 @@ export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {}; const validate = async () => {};
const onSubmit = async () => {}; const onSubmit = async () => {};
const { data } = props;
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => { const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return ( return (
<AsyncSelectPaginate <AsyncSelectPaginate
@@ -58,59 +56,90 @@ export const EditMetadataPanel = (props): ReactElement => {
<label className="label">Issue Details</label> <label className="label">Issue Details</label>
</div> </div>
<div className="field-body"> <div className="field-body">
<Field <div className="field">
name="issue_name" <p className="control is-expanded has-icons-left">
component="input" <Field
className="appearance-none w-full dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" name="issue_name"
initialValue={data.name} component="input"
placeholder={"Issue Name"} className="input"
/> initialValue={rawFileDetails}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
</div> </div>
</div> </div>
{/* Issue Number and year */} {/* Issue Number and year */}
<div className="mt-4 flex flex-row gap-2"> <div className="field is-horizontal">
<div> <div className="field-label"></div>
<div className="text-sm">Issue Number</div> <div className="field-body">
<Field <div className="field">
name="issue_number" <p className="control has-icons-left">
component="input" <Field
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" name="issue_number"
placeholder="Issue Number" component="input"
/> className="input"
<p className="text-xs">Do not enter the first zero</p> placeholder="Issue Number"
</div> />
<div> <span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
</div>
{/* year */} {/* year */}
<div className="text-sm">Issue Year</div> <div className="field">
<Field <p className="control">
name="issue_year" <Field
component="input" name="issue_year"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" component="input"
/> className="input"
</div> />
</p>
<div> </div>
<div className="text-sm">Page Count</div>
<Field
name="page_count"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Page Count"
/>
</div> </div>
</div> </div>
{/* page count */} {/* page count */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="page_count"
component="input"
className="input"
placeholder="Page Count"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-note-sticky"></i>
</span>
</p>
</div>
</div>
</div>
{/* Description */} {/* Description */}
<div className="mt-2"> <div className="field is-horizontal">
<label className="text-sm">Description</label> <div className="field-label is-normal">
<Field <label className="label">Description</label>
name={"description"} </div>
className="dark:bg-slate-400 w-full min-h-24 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" <div className="field-body">
component={TextareaAutosizeAdapter} <div className="field">
placeholder={"Description"} <p className="control is-expanded has-icons-left">
/> <Field
name={"description"}
className="textarea"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
</div> </div>
<hr size="1" /> <hr size="1" />

View File

@@ -1,9 +1,8 @@
import React from "react"; import React, { useCallback } from "react";
import { isNil, map } from "lodash"; import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
interface MatchResultProps { interface MatchResultProps {
matchData: any; matchData: any;
@@ -15,24 +14,14 @@ const handleBrokenImage = (e) => {
}; };
export const MatchResult = (props: MatchResultProps) => { export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (match, comicObjectId) => { const applyCVMatch = useCallback(
return await axios.request({ // (match, comicObjectId) => {
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`, // dispatch(applyComicVineMatch(match, comicObjectId));
method: "POST", // },
data: { [],
match, );
comicObjectId,
},
});
};
return ( return (
<> <>
<span className="flex items-center mt-6">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
ComicVine Matches
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
{map(props.matchData, (match, idx) => { {map(props.matchData, (match, idx) => {
let issueDescription = ""; let issueDescription = "";
if (!isNil(match.description)) { if (!isNil(match.description)) {
@@ -42,90 +31,88 @@ export const MatchResult = (props: MatchResultProps) => {
}, },
}); });
} }
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return ( return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}> <div className="search-result mb-4" key={idx}>
<div className="flex flex-row gap-4"> <div className="columns">
<div className="min-w-fit"> <div className="column is-one-fifth">
<img <img
className="rounded-md" className="cover-image"
src={match.image.thumb_url} src={match.image.thumb_url}
onError={handleBrokenImage} onError={handleBrokenImage}
/> />
</div> </div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
{/* score */} <div className="search-result-details column">
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400"> <div className="is-size-5">{match.name}</div>
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i> <div className="field is-grouped is-grouped-multiline mt-1">
</span> <div className="control">
<span className="text-slate-900 dark:text-slate-900"> <div className="tags has-addons">
{parseInt(match.score, 10)} <span className="tag">Number</span>
</span> <span className="tag is-primary">
</span> {match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div> </div>
<span className="flex flex-row gap-2 mb-2"> <div className="is-size-7">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.issue_number, 10)}
</span>
</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--calendar-mark-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
Cover Date: {match.cover_date}
</span>
</span>
</span>
<div className="text-sm">
{ellipsize(issueDescription, 300)} {ellipsize(issueDescription, 300)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-row gap-2 my-4 ml-10"> <div className="vertical-line"></div>
<div className="">
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img <img
src={match.volumeInformation.results.image.icon_url} src={match.volumeInformation.results.image.icon_url}
className="rounded-md w-full" className="cover-image"
onError={handleBrokenImage} onError={handleBrokenImage}
/> />
</div> </div>
<div className=""> <div className="column">
<span>{match.volume.name}</span> <div className="is-size-6">{match.volume.name}</div>
<div className="text-sm"> <div className="field is-grouped is-grouped-multiline mt-2">
<p> <div className="control">
Total Issues: <div className="tags has-addons">
{match.volumeInformation.results.count_of_issues} <span className="tag">Total Issues</span>
</p> <span className="tag is-warning">
<p> {match.volumeInformation.results.count_of_issues}
Published by{" "} </span>
{match.volumeInformation.results.publisher.name} </div>
</p> </div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="columns">
<button <div className="column">
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" <button
onClick={() => applyCVMatch(match, props.comicObjectId)} className="button is-normal is-outlined is-primary is-light is-pulled-right"
> onClick={() => applyCVMatch(match, props.comicObjectId)}
<span className="text-md">Apply Match</span> >
<span className="w-5 h-5"> <span className="icon is-size-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i> <i className="fas fa-clipboard-check"></i>
</span> </span>
</button> <span>Apply Match</span>
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,157 +1,167 @@
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; const PaperClipIcon = () => <></>;
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">
<p className="text-gray-500 dark:text-gray-400"> <span className="text-xl">{rawFileDetails.name}</span>
<span className="text-xl font-semibold">{rawFileDetails?.name}</span>
</p> </p>
</div> </div>
<div className="px-4 py-5 sm:px-6">
{/* 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">
<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">
</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">
)} Inferred Issue Metadata
</dt>
<dd className="mt-1 text-sm text-gray-900">
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">MIMEType</dt>
<dd className="mt-1 text-sm text-gray-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>
<dd className="text-sm text-gray-900"></dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">File Size</dt>
<dd className="mt-1 text-sm text-gray-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">
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900">
{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">Attachments</dt>
<dd className="mt-1 text-sm text-gray-900">
<ul
role="list"
className="divide-y divide-gray-200 rounded-md border border-gray-200"
>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
<PaperClipIcon
className="h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
<span className="ml-2 w-0 flex-1 truncate">
resume_back_end_developer.pdf
</span>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => {}}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
</ul>
</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,
}),
};

View File

@@ -1,43 +1,43 @@
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useEffect, useState } from "react";
import { isNil } from "lodash"; import { isEmpty, isNil } from "lodash";
export const TabControls = (props): ReactElement => { export const TabControls = (props): ReactElement => {
const { filteredTabs, downloadCount } = props; // const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, acquisition } = props;
const [active, setActive] = useState(filteredTabs[0].id); const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
setActive(filteredTabs[0].id);
}, [acquisition]);
return ( return (
<> <>
<div className="hidden sm:block mt-7 mb-3 w-fit"> <div className="hidden sm:block mt-7">
<div className="border-b border-gray-200"> <div className="border-b border-gray-200">
<nav className="flex gap-6" aria-label="Tabs"> <nav className="-mb-px flex gap-6" aria-label="Tabs">
{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-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 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)}
> >
{/* Downloads tab and count badge */} {/* Downloads tab and count badge */}
<> {/* <a>
{id === 6 && !isNil(downloadCount) ? ( {id === 6 && !isNil(acquisition.directconnect) ? (
<span className="inline-flex flex-row"> <span className="download-icon-labels">
{/* download count */} <i className="fa-solid fa-download"></i>
<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="tag downloads-count is-info is-light">
<span className="text-md text-slate-500 dark:text-slate-900"> {acquisition.directconnect.downloads.length}
{icon}
</span>
</span>
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
</span> </span>
) : ( </span>
<span className="w-5 h-5">{icon}</span> ) : (
)} <span className="icon is-small">{icon}</span>
{name} )}
</> {name}
</a> */}
{name}
</a> </a>
))} ))}
</nav> </nav>

View File

@@ -1,164 +1,57 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD"; import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane"; import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas"; import { Canvas } from "../../shared/Canvas";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import {
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../../constants/endpoints";
import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow";
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 isComicBookExtractionInProgress = useSelector(
// (state: RootState) => state.fileOps.comicBookExtractionInProgress,
// );
// const extractedComicBookArchive = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
// );
//
// const imageAnalysisResult = useSelector((state: RootState) => {
// return state.fileOps.imageAnalysisResults;
// });
const unpackComicArchive = useCallback(() => {
// dispatch(
// extractComicArchive(data.rawFileDetails.filePath, {
// type: "full",
// purpose: "analysis",
// imageResizeOptions: {
// baseWidth: 275,
// },
// }),
// );
}, []);
const { getSocket } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
})),
);
const queryClient = useQueryClient();
// sliding panel config // sliding panel config
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 [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false);
const constructImagePaths = (data: string[]): Array<string> => {
return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
);
};
// Listen to the uncompression complete event and orchestrate the final payload
useEffect(() => {
const socket = getSocket("/");
const handleUncompressionComplete = (data: any) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
};
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
// Cleanup listener on unmount
return () => {
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
};
}, [getSocket]);
useEffect(() => {
let isMounted = true;
if (data.rawFileDetails?.archive?.uncompressed) {
const fetchUncompressedArchive = async () => {
try {
const response = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
method: "POST",
data: {
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
},
transformResponse: async (responseData) => {
const parsedData = JSON.parse(responseData);
const paths = parsedData.map((pathObject: any) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
});
const uncompressedArchive = constructImagePaths(paths);
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
}
},
});
} catch (error) {
console.error("Error fetching uncompressed archive:", error);
// Handle error if necessary
}
};
fetchUncompressedArchive();
}
// Cleanup function
return () => {
isMounted = false;
setUncompressedArchive([]);
};
}, [data]);
const analyzeImage = async (imageFilePath: string) => {
const response = await axios({
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
method: "POST",
data: {
imageFilePath,
},
});
setImageAnalysisResult(response?.data);
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
};
const {
data: uncompressionResult,
refetch,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
method: "POST",
url: `http://localhost:3000/api/library/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: data.rawFileDetails.filePath,
comicObjectId: data._id,
options: {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
},
},
}),
queryKey: ["uncompressedArchive"],
enabled: false,
});
if (isSuccess && shouldRefetchComicBookData) {
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 (
<div> <div>
<pre className="text-sm">{currentImage}</pre> <pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? ( {!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3"> <pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} /> <Canvas data={imageAnalysisResult} />
</pre> </pre>
) : null} ) : null}
<pre className="font-hasklig mt-3 text-sm"> <pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)} {JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre> </pre>
</div> </div>
); );
@@ -167,83 +60,56 @@ 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); // dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath); setCurrentImage(imageFilePath);
setVisible(true); setVisible(true);
}, []); }, []);
return ( return (
<div key={2}> <div key={2}>
<article <button
role="alert" className={
className="mt-4 text-md 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" isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
> >
<div> <span className="icon is-small">
<p>You can perform several operations on your comic book archive.</p> <i className="fa-solid fa-box-open"></i>
<p> </span>
Uncompressing, re-organizing the individual pages, then <span>Unpack comic archive</span>
re-compressing to a different format, for example. </button>
</p> <div className="columns">
<p>You can also analyze color histograms of pages.</p> <div className="mt-5">
</div> {!isEmpty(extractedComicBookArchive) ? (
</article>
<div className="mt-5">
{data.rawFileDetails.archive?.uncompressed &&
!isEmpty(uncompressedArchive) ? (
<article
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"
>
This issue is already uncompressed at:
<p>
<code className="font-hasklig text-sm">
{data.rawFileDetails.archive.expandedPath}
</code>
<div className="">It has {uncompressedArchive?.length} pages</div>
</p>
</article>
) : null}
<div className="flex flex-row gap-2 mt-4">
{isEmpty(uncompressedArchive) ? (
<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"
onClick={() => refetch()}
>
<span className="text-md">Unpack Comic Archive</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
</span>
</button>
) : null}
{!isEmpty(uncompressedArchive) ? (
<div>
<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"
onClick={() => refetch()}
>
<span className="text-md">Convert to .cbz</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
</span>
</button>
</div>
) : null}
</div>
</div>
<div>
<div className="mt-10">
{!isEmpty(uncompressedArchive) ? (
<DnD <DnD
data={uncompressedArchive} data={extractedComicBookArchive}
onClickHandler={openImageAnalysisPanel} onClickHandler={openImageAnalysisPanel}
/> />
) : null} ) : null}
</div> </div>
{!isEmpty(extractedComicBookArchive) ? (
<div className="column mt-5">
<Sticky enabled={true} top={70} bottomBoundary={3000}>
<div className="card">
<div className="card-content">
<span className="has-text-size-4">
{extractedComicBookArchive.length} pages
</span>
<button className="button is-small is-light is-primary is-outlined">
<span className="icon is-small">
<i className="fa-solid fa-compress"></i>
</span>
<span>Convert to CBZ</span>
</button>
</div>
</div>
</Sticky>
</div>
) : null}
</div> </div>
<SlidingPane <SlidingPane
isOpen={visible} isOpen={visible}

View File

@@ -4,59 +4,52 @@ import React, { ReactElement } from "react";
export const ComicInfoXML = (data): ReactElement => { export const ComicInfoXML = (data): ReactElement => {
const { json } = data; const { json } = data;
return ( return (
<div className="flex md:w-4/5 lg:w-78"> <div className="comicInfo-metadata">
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg"> <dl className="has-text-size-7">
<dt> <dd className="has-text-weight-medium">{json.series[0]}</dd>
<p className="text-lg">{json.series[0]}</p> <dd className="mt-2 mb-2">
</dt> <div className="field is-grouped is-grouped-multiline">
<dd className="text-sm"> <div className="control">
published by{" "} <span className="tags has-addons">
<span className="underline"> <span className="tag">Pages</span>
{json.publisher[0]} <span className="tag is-warning is-light">
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" /> {json.publisher[0]}
</span>
</dd>
<span className="inline-flex flex-row gap-2">
{/* Issue number */}
{!isUndefined(json.number) && (
<dd className="my-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(json.number[0], 10)}
</span> </span>
</span> </span>
</dd> </div>
)} <div className="control">
<dd className="my-2"> <span className="tags has-addons">
{/* Genre */} <span className="tag">Issue #</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="tag is-warning is-light">
<span className="pr-1 pt-1"> {!isUndefined(json.number) && parseInt(json.number[0], 10)}
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i> </span>
</span> </span>
</div>
<span className="text-slate-500 dark:text-slate-900"> <div className="control">
{json.genre[0]} <span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.pagecount[0]}
</span>
</span> </span>
</span> </div>
</dd> {!isUndefined(json.genre) && (
</span> <div className="control">
<span className="tags has-addons">
<dd className="my-1"> <span className="tag">Genre</span>
{/* Summary */} <span className="tag is-success is-light">
{!isUndefined(json.summary) && ( {json.genre[0]}
<span className="text-md text-slate-500 dark:text-slate-900"> </span>
{json.summary[0]} </span>
</span> </div>
)} )}
</div>
</dd> </dd>
<dd> <dd>
{/* Notes */} <span className="is-size-7">{json.notes[0]}</span>
<span className="text-sm text-slate-500 dark:text-slate-900"> </dd>
{json.notes[0]} <dd className="mt-1 mb-1">
</span> {!isUndefined(json.summary) && json.summary[0]}
</dd> </dd>
</dl> </dl>
</div> </div>

View File

@@ -1,15 +1,30 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import ComicVineDetails from "../ComicVineDetails"; import ComicVineDetails from "../ComicVineDetails";
import { convert } from "html-to-text";
import { isEmpty } from "lodash";
export const VolumeInformation = (props): ReactElement => { export const VolumeInformation = (props): ReactElement => {
const { data } = props; const { data } = props;
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return ( return (
<div key={1}> <div key={1}>
<ComicVineDetails <div className="columns is-multiline">
data={data.sourcedMetadata.comicvine} <ComicVineDetails
updatedAt={data.updatedAt} data={data.sourcedMetadata.comicvine}
/> updatedAt={data.updatedAt}
/>
<div className="column is-8">
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
convert(data.sourcedMetadata.comicvine.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</div> </div>
); );
}; };

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

@@ -1,13 +1,29 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import ZeroState from "./ZeroState"; import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported"; import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList"; 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 { useQuery } from "@tanstack/react-query"; import {
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil, isUndefined } from "lodash";
import Header from "../shared/Header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import { Card } from "../shared/Carda";
import {
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../constants/endpoints";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
export const Dashboard = (): ReactElement => { export const Dashboard = (): ReactElement => {
const { data: recentComics } = useQuery({ const { data: recentComics } = useQuery({
@@ -21,61 +37,152 @@ 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({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: {
wanted: { $exists: true, $ne: null },
},
},
}),
queryKey: ["wantedComics"],
});
const { data: volumeGroups } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
}),
queryKey: ["volumeGroups"],
});
const { data: statistics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
method: "GET",
}),
queryKey: ["libraryStatistics"],
});
console.log("hari om", recentComics);
// useEffect(() => {
// dispatch(fetchVolumeGroups());
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": false },
// comicStatus: "recent",
// }),
// );
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": true },
// comicStatus: "wanted",
// }),
// );
// dispatch(getLibraryStatistics());
// }, []);
//
// const recentComics = useSelector(
// (state: RootState) => state.fileOps.recentComics,
// );
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
// const volumeGroups = useSelector(
// (state: RootState) => state.fileOps.comicVolumeGroups,
// );
//
// const libraryStatistics = useSelector(
// (state: RootState) => state.comicInfo.libraryStatistics,
// );
return ( return (
<div className="container mx-auto max-w-full"> <div className="container mx-auto max-w-full">
<PullList /> <section>
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />} <h1>Dashboard</h1>
{/* Wanted comics */} <div className="grid grid-cols-5 gap-6">
<WantedComicsList comics={wantedComics?.data?.docs} /> {recentComics?.data.docs.map(
{/* Library Statistics */} (
{statistics && <LibraryStatistics stats={statistics?.data} />} {
{/* Volume groups */} _id,
<VolumeGroups volumeGroups={volumeGroups?.data} /> rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(
name,
{
comicvine,
comicInfo,
locg,
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
return (
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dt className="sr-only">Address</dt>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<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-outline]"></i>
</span>
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<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--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
</dd>
</div>
<div className="flex flex-row items-center gap-1 my-2">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300"></i>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
);
},
)}
</div>
</section>
</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,178 +1,160 @@
import React, { ReactElement, useState, useEffect } from "react"; import { isNil, map } from "lodash";
import { map } from "lodash"; import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import Header from "../shared/Header"; import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions"; import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import { setupCache } from "axios-cache-interceptor";
import { useQuery } from "@tanstack/react-query";
import "keen-slider/keen-slider.min.css";
import { useKeenSlider } from "keen-slider/react";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { Field, Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
type PullListProps = { type PullListProps = {
issues: any; issues: any;
}; };
const http = rateLimiter(axios.create(), { export const PullList = ({ issues }: PullListProps): ReactElement => {
maxRequests: 1, const dispatch = useDispatch();
perMilliseconds: 1000,
maxRPS: 1,
});
const cachedAxios = setupCache(axios);
export const PullList = (): ReactElement => {
// datepicker
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "yyyy/M/dd"),
);
// Responsive slides per view
const [slidesPerView, setSlidesPerView] = useState(1);
// keen slider
const [sliderRef, instanceRef] = useKeenSlider({
loop: true,
mode: "free-snap",
slides: {
perView: slidesPerView,
spacing: 15,
},
slideChanged() {
console.log("slide changed");
},
});
// Update slider when slidesPerView changes
useEffect(() => { useEffect(() => {
if (instanceRef.current) { dispatch(
instanceRef.current.update({ getWeeklyPullList({
slides: { startDate: "2023-9-9",
perView: slidesPerView, pageSize: "15",
spacing: 15, currentPage: "1",
},
});
}
}, [slidesPerView, instanceRef]);
const {
data: pullList,
refetch,
isSuccess,
isLoading,
isError,
} = useQuery({
queryFn: async (): any =>
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
}), }),
queryKey: ["pullList", inputValue], );
}); }, []);
const addToLibrary = (sourceName: string, locgMetadata) => const addToLibrary = useCallback(
importToDB(sourceName, { locg: locgMetadata }); (sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
);
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
};
*/
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => { const next = () => {
// sliderRef.slickNext(); sliderRef.slickNext();
}; };
const previous = () => { const previous = () => {
// sliderRef.slickPrev(); sliderRef.slickPrev();
}; };
return ( return (
<> <>
<div className="content"> <div className="content">
<div className="mx-auto"> <Header headerContent="Discover"
<Header subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
headerContent="Discover" iconClassNames="fa-solid fa-binoculars mr-2"/>
subHeaderContent={ <div className="field is-grouped">
<span className="text-md"> {/* select week */}
Pull List aggregated for the week from{" "} <div className="control">
<span className="underline"> <div className="select is-small">
<a href="https://leagueofcomicgeeks.com"> <select>
League Of Comic Geeks <option>Select Week</option>
</a> <option>With options</option>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" /> </select>
</span> </div>
</span> </div>
} {/* See all pull list issues */}
iconClassNames="fa-solid fa-binoculars mr-2" <div className="control">
link="/pull-list/all/" <Link to={"/pull-list/all/"}>
/> <button className="button is-small">View all issues</button>
<div className="flex flex-row gap-5 mb-3"> </Link>
{/* select week */} </div>
<div className="flex flex-row gap-4 my-3"> <div className="field has-addons">
<Form <div className="control">
onSubmit={() => {}} <button className="button is-rounded is-small" onClick={previous}>
render={({ handleSubmit }) => ( <i className="fa-solid fa-caret-left"></i>
<form> </button>
<div className="flex flex-col gap-2"> </div>
{/* week selection for pull list */} <div className="control">
<DatePickerDialog <button className="button is-rounded is-small" onClick={next}>
inputValue={inputValue} <i className="fa-solid fa-caret-right"></i>
setter={setInputValue} </button>
/>
{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> </div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{isSuccess && !isLoading && ( {!isNil(pullList) &&
<div ref={sliderRef} className="keen-slider"> pullList &&
{map(pullList?.data.result, (issue, idx) => { map(pullList, ({ issue }, idx) => {
return ( return (
<div key={idx} className="keen-slider__slide">
<Card <Card
orientation={"vertical-2"} key={idx}
imageUrl={issue.coverImageUrl} orientation={"vertical"}
imageUrl={issue.cover}
hasDetails hasDetails
title={ellipsize(issue.issueName, 25)} title={ellipsize(issue.name, 18)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
> >
<div className="px-1"> <div className="content">
<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"> <div className="control">
{issue.publicationDate} <span className="tag">{issue.publisher}</span>
</span> </div>
<div className="flex flex-row justify-end"> <div className="mt-2">
<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="button is-small is-success is-outlined is-light"
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="fa-solid fa-plus"></i> Want
Want
</button> </button>
</div> </div>
</div> </div>
</Card> </Card>
</div> );
);
})} })}
</div> </Slider>
)}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</> </>
); );
}; };

View File

@@ -4,38 +4,50 @@ import { Link } from "react-router-dom";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { import {
determineCoverFile, determineCoverFile,
determineExternalMetadata, determineExternalMetadata,
} from "../../shared/utils/metadata.utils"; } from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import Header from "../shared/Header";
type RecentlyImportedProps = { type RecentlyImportedProps = {
comics: any; comicBookCovers: any;
}; };
export const RecentlyImported = ( export const RecentlyImported = ({
comics: RecentlyImportedProps, comicBookCovers,
): ReactElement => { }: RecentlyImportedProps): ReactElement => {
console.log(comics); const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
return ( return (
<div> <>
<div className="mx-auto" style={{ maxWidth: '1400px' }}> <div className="content mt-5">
<Header <h4 className="title is-4">
headerContent="Recently Imported" <i className="fa-solid fa-file-arrow-down"></i> Recently Imported
subHeaderContent="Recent Library activity such as imports, tagging, etc." </h4>
iconClassNames="fa-solid fa-binoculars mr-2" <p className="subtitle is-7">
/> Recent Library activity such as imports, tagging, etc.
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 gap-6 mt-3"> </p>
{comics?.comics.map( </div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comicBookCovers,
( (
{ {
_id, _id,
rawFileDetails, rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg }, sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata, acquisition: {
wanted: { source } = {}, source: { name },
},
}, },
idx, idx,
) => { ) => {
@@ -45,89 +57,90 @@ 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 isComicBookMetadataAvailable =
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return ( return (
<Card <React.Fragment key={_id}>
orientation="vertical-2" <Card
key={idx} orientation={"vertical"}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`} imageUrl={url}
title={inferredMetadata.issue.name} hasDetails
hasDetails title={issueName ? titleElement : null}
> >
<div> <div className="content is-flex is-flex-direction-row">
<dd className="text-sm my-1 flex flex-row gap-1"> {/* Raw file presence */}
{/* Issue number */} {isNil(rawFileDetails) && (
<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="icon custom-icon is-small has-text-danger mr-2">
<span className="pr-1 pt-1"> <img src="/src/client/assets/img/missing-file.svg" />
<i className="icon-[solar--hashtag-outline]"></i>
</span> </span>
<span className="text-md text-slate-900"> )}
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<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--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
{/* Uncompressed status */}
{rawFileDetails?.archive?.uncompressed ? (
<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--bookmark-bold-duotone] w-4 h-4"></i>
</span>
</span>
) : null}
</dd>
</div>
<div className="flex flex-row items-center gap-1 mt-2 pb-1">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */} {/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && ( {!isNil(comicInfo) && !isEmpty(comicInfo) && (
<div mt-1> <span className="icon custom-icon is-small has-text-danger">
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i> <img
</div> src="/src/client/assets/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)} )}
{/* ComicVine metadata presence */} {/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && ( {isComicBookMetadataAvailable && (
<span className="w-7 h-7"> <span className="icon custom-icon">
<img <img
src="/src/client/assets/img/cvlogo.svg" src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."} alt={"ComicVine metadata detected."}
/> />
</span> </span>
)} )}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div> </div>
{/* Raw file presence */} </Card>
{isNil(rawFileDetails) && ( {/* metadata card */}
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2"> {!isNil(name) && (
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" /> <Card orientation="horizontal" hasDetails imageUrl={coverURL}>
</span> <dd className="is-size-9">
)} <dl>
</div> <span className="icon custom-icon">
</Card> <img src={`/src/client/assets/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
); );
}, },
)} )}
</div> </Masonry>
</div> </>
</div>
); );
}; };

View File

@@ -2,10 +2,15 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import Card from "../shared/Carda"; import Masonry from "react-masonry-css";
import Header from "../shared/Header";
export const VolumeGroups = (props): ReactElement => { export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId // Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id"); const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate(); const navigate = useNavigate();
@@ -14,48 +19,44 @@ export const VolumeGroups = (props): ReactElement => {
}; };
return ( return (
<section> <section className="volumes-container mt-4">
<Header <div className="content">
headerContent="Volumes" <a className="mb-1" onClick={navigateToVolumes}>
subHeaderContent="Based on ComicVine Volume information" <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-layer-group"></i> Volumes
link={"/volumes"} </span>
/> <span className="icon mt-1">
<div className="grid grid-cols-5 gap-6 mt-3"> <i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
{map(deduplicatedGroups, (data) => { {map(deduplicatedGroups, (data) => {
return ( return (
<div className="max-w-sm mx-auto" key={data._id}> <div className="stack" key={data._id}>
<Card <img src={data.volumes.image.small_url} />
orientation="vertical-2" <div className="content">
key={data._id} <div className="stack-title is-size-8">
imageUrl={data.volumes.image.small_url} <Link to={`/volume/details/${data._id}`}>
hasDetails {ellipsize(data.volumes.name, 18)}
> </Link>
<div className="py-3"> </div>
<div className="text-sm"> <div className="control">
<Link to={`/volume/details/${data._id}`}> <span className="tags has-addons">
{ellipsize(data.volumes.name, 48)} <span className="tag is-primary is-light">Issues</span>
</Link> <span className="tag">{data.volumes.count_of_issues}</span>
</div>
{/* issue count */}
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{data.volumes.count_of_issues} issues
</span>
</span> </span>
</div> </div>
</Card> </div>
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
</div> </div>
); );
})} })}
</div> </Masonry>
</section> </section>
); );
}; };

View File

@@ -4,9 +4,8 @@ import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
type WantedComicsListProps = { type WantedComicsListProps = {
comics: any; comics: any;
@@ -15,128 +14,101 @@ type WantedComicsListProps = {
export const WantedComicsList = ({ export const WantedComicsList = ({
comics, comics,
}: WantedComicsListProps): ReactElement => { }: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToWantedComics = (row) => {
// embla carousel navigate(`/wanted/all`);
const [emblaRef, emblaApi] = useEmblaCarousel({ };
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return ( return (
<div> <>
<Header <div className="content mt-6">
headerContent="Wanted Comics" <a className="mb-1" onClick={navigateToWantedComics}>
subHeaderContent="Comics marked as wanted from various sources" <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-asterisk"></i> Wanted Comics
link={"/wanted"} </span>
/> <span className="icon mt-1">
<div className="overflow-hidden -mr-4 sm:-mr-8 lg:-mr-16 xl:-mr-20 2xl:-mr-24 mt-3"> <i className="fa-solid fa-angle-right"></i>
<div className="overflow-hidden" ref={emblaRef}> </span>
<div className="flex"> </a>
{map( <p className="subtitle is-7">
comics, Comics marked as wanted from various sources.
( </p>
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
wanted,
},
idx,
) => {
const isComicBookMetadataAvailable = !isUndefined(comicvine);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const {
issueName,
url,
publisher = null,
} = determineCoverFile(consolidatedComicMetadata);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
<p>{publisher}</p>
</Link>
);
return (
<div
key={idx}
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
>
<Card
orientation={"vertical-2"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="pb-1">
<div className="flex flex-row gap-2">
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(detectIssueTypes(comicvine.description)) ? (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(comicvine.description)
.displayName
}
</span>
</span>
</div>
) : null}
{/* issues marked as wanted, part of this volume */}
{wanted?.markEntireVolumeWanted ? (
<div className="text-sm">sagla volume pahije</div>
) : (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{wanted.issues.length}
</span>
</span>
</div>
)}
</div>
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div>
</Card>
</div>
);
},
)}
</div>
</div>
</div> </div>
</div> <Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comics,
({
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/locglogo.svg" />
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
);
},
)}
</Masonry>
</>
); );
}; };

View File

@@ -6,8 +6,8 @@ interface ZeroStateProps {
} }
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => { const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
return ( return (
<article className=""> <article className="message is-info">
<div className=""> <div className="message-body">
<p>{props.header}</p> <p>{props.header}</p>
{props.message} {props.message}
</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),
); );
}; };
/** /**
@@ -302,7 +226,7 @@ export const Import = (props: IProps): ReactElement => {
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span> </span>
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200"> <div className="overflow-x-auto 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"> <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead className="ltr:text-left rtl:text-right"> <thead className="ltr:text-left rtl:text-right">
<tr> <tr>
@@ -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

@@ -1,16 +1,11 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react"; import React, { useMemo, ReactElement, useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { import { useQuery, keepPreviousData } from "@tanstack/react-query";
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns"; import { format, fromUnixTime, parseISO } from "date-fns";
@@ -25,56 +20,32 @@ export const Library = (): ReactElement => {
// Default page state // Default page state
// offset: 0 // offset: 0
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({
query: {},
pagination: {
size: 25,
from: offset,
},
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/** // Method to fetch paginated issues
* Method that queries the Elasticsearch index "comics" for issues specified by the query const fetchIssues = async (searchQuery, offset, type) => {
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params. let pagination = {
*/ size: 15,
const fetchIssues = async (searchQuery) => { from: offset,
const { pagination, query, type } = searchQuery; };
return await axios({ return await axios({
method: "POST", method: "POST",
url: "http://localhost:3000/api/search/searchIssue", url: "http://localhost:3000/api/search/searchIssue",
data: { data: {
query, searchQuery,
pagination, pagination,
type, type,
}, },
}); });
}; };
const searchIssues = (e) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({ const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset, searchQuery], queryKey: ["comics", offset],
queryFn: () => fetchIssues(searchQuery), queryFn: () => fetchIssues({}, offset, "all"),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const searchResults = data?.data; const searchResults = data?.data;
// Programmatically navigate to comic detail // Programmatically navigate to comic detail
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToComicDetail = (row) => { const navigateToComicDetail = (row) => {
@@ -85,7 +56,7 @@ export const Library = (): ReactElement => {
return value.data ? ( return value.data ? (
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max"> <dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
{/* Series Name */} {/* Series Name */}
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1"> <span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i> <i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
</span> </span>
@@ -152,10 +123,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-xs w-max ml-3 my-3 text-slate-600">
<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 +135,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>
), ),
}, },
@@ -202,17 +171,7 @@ export const Library = (): ReactElement => {
**/ **/
const nextPage = (pageIndex: number, pageSize: number) => { const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) { if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] }); setOffset(pageSize * pageIndex + 1);
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
} }
}; };
@@ -230,17 +189,7 @@ export const Library = (): ReactElement => {
} else { } else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1); from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
} }
queryClient.invalidateQueries({ queryKey: ["comics"] }); setOffset(from);
setSearchQuery({
query: {},
pagination: {
size: 15,
from,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(from);
}; };
// ImportStatus.propTypes = { // ImportStatus.propTypes = {
@@ -266,7 +215,7 @@ export const Library = (): ReactElement => {
</header> </header>
{!isUndefined(searchResults?.hits) ? ( {!isUndefined(searchResults?.hits) ? (
<div> <div>
<div> <div className="library">
<T2Table <T2Table
totalPages={searchResults.hits.total.value} totalPages={searchResults.hits.total.value}
columns={columns} columns={columns}
@@ -276,13 +225,11 @@ export const Library = (): ReactElement => {
nextPage, nextPage,
previousPage, previousPage,
}} }}
> />
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mx-auto max-w-screen-xl mt-5"> <>
<article <article
role="alert" role="alert"
className="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="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"
@@ -300,7 +247,7 @@ export const Library = (): ReactElement => {
{!isUndefined(searchResults?.data?.meta?.body) ? ( {!isUndefined(searchResults?.data?.meta?.body) ? (
<p> <p>
{JSON.stringify( {JSON.stringify(
searchResults?.data.meta.body.error.root_cause, searchResults.data.meta.body.error.root_cause,
null, null,
4, 4,
)} )}
@@ -308,7 +255,7 @@ export const Library = (): ReactElement => {
) : null} ) : null}
</pre> </pre>
</div> </div>
</div> </>
)} )}
</section> </section>
</div> </div>

View File

@@ -1,13 +1,32 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (props): ReactElement => { export const SearchBar = (): ReactElement => {
const { searchHandler } = props; const handleSubmit = useCallback((e) => {
// dispatch(
// searchIssue(
// {
// query: {
// volumeName: e.search,
// },
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumeName",
// trigger: "libraryPage",
// },
// ),
// );
}, []);
return ( return (
<Form <Form
onSubmit={searchHandler} onSubmit={handleSubmit}
initialValues={{}} initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>

View File

@@ -1,21 +1,15 @@
import React, { ReactElement, useState } from "react"; import React, { useCallback, ReactElement } 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 { useSelector, useDispatch } from "react-redux";
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 {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {} interface ISearchProps {}
@@ -23,465 +17,164 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const [comicVineMetadata, setComicVineMetadata] = useState({}); const dispatch = useDispatch();
const [selectedResource, setSelectedResource] = useState("volume"); const getCVSearchResults = useCallback(
const { t } = useTranslation(); (searchQuery) => {
const handleResourceChange = (value) => { dispatch(
setSelectedResource(value); comicinfoAPICall({
}; callURIAction: "search",
callMethod: "GET",
const { callParams: {
mutate, api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
data: comicVineSearchResults, query: searchQuery.search,
isPending, format: "json",
isSuccess, limit: "10",
} = useMutation({ offset: "0",
mutationFn: async (data: { search: string; resource: string }) => { field_list:
const { search, resource } = data; "id,name,deck,api_detail_url,image,description,volume,cover_date",
return await axios({ resources: "issue",
url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: resource,
},
});
},
});
// add to library
const { data: additionResult, mutate: addToWantedList } = useMutation({
mutationFn: async ({
source,
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`,
method: "POST",
data: {
importType: "new",
payload: {
importStatus: {
isImported: false, // wanted, but not acquired yet.
tagged: false,
matchedResult: {
score: "0",
},
},
wanted: {
source,
markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
}, },
}, }),
}); );
}, },
}); [dispatch],
);
const addToLibrary = (sourceName: string, comicData) => const addToLibrary = useCallback(
setComicVineMetadata({ sourceName, comicData }); (sourceName: string, comicData) =>
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => { const createDescriptionMarkup = (html) => {
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> <>
<section> <section className="container">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="section search">
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Search</h1>
<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">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<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,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className="form columns search">
<div className="flex flex-row w-full"> <div className="column is-three-quarters search">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full"> <Field name="search">
<div className="w-10 text-gray-400"> {({ input, meta }) => {
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" /> return (
</div> <input
{...input}
<Field name="search"> className="input main-search-bar is-large"
{({ input, meta }) => { placeholder="Type an issue/volume name"
return ( />
<input );
{...input} }}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full" </Field>
placeholder="Type an issue/volume name" </div>
/> <div className="column">
); <button type="submit" className="button is-medium">
}}
</Field>
</div>
<button
className="sm:mt-0 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"
>
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> {!isNil(comicVineSearchResults.results) &&
{isPending && ( !isEmpty(comicVineSearchResults.results) ? (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="columns is-multiline">
Loading results... {comicVineSearchResults.results.map((result) => {
</div> return (
)}
{!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) => {
return result.resource_type === "issue" ? (
<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"
>
{/* IMAGE */}
<div className="flex-shrink-0">
<Card
orientation="cover-only"
imageUrl={result.image.small_url}
hasDetails={false}
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
/>
</div>
{/* RIGHT-SIDE CONTENT */}
<div className="flex-1 min-w-0">
{/* TITLE */}
<div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
{result.volume?.name || <span>No Name</span>}
</div>
{/* SUBMETA */}
<div className="flex flex-wrap gap-2 mt-2">
{/* Cover Date Token */}
{result.cover_date && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{dayjs(result.cover_date).format("MMM YYYY")}
</span>
</span>
)}
{/* ID Token */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{result.id}
</span>
</span>
</div>
{/* LINK */}
<a
href={result.api_detail_url}
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
>
{result.api_detail_url}
</a>
{/* DESCRIPTION */}
{result.description && (
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
{ellipsize(
convert(result.description ?? "", {
baseElements: { selectors: ["p", "div"] },
}),
300,
)}
</p>
)}
{/* CTA BUTTON */}
{result.volume.name && (
<div className="absolute bottom-4 right-4">
<PopoverButton
content={`This will add ${result?.volume?.name} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
)}
</div>
</div>
) : (
result.resource_type === "volume" && (
<div <div
key={result.id} 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" className="comicvine-metadata column is-10 mb-3"
> >
{/* LEFT COLUMN: COVER */} <div className="columns">
<Card <div className="column is-one-quarter">
orientation="cover-only" <Card
imageUrl={result.image.small_url} key={result.id}
hasDetails={false} orientation={"vertical"}
cardContainerStyle={{ imageUrl={result.image.small_url}
width: "120px", title={result.name}
maxWidth: "150px", hasDetails={false}
}} ></Card>
/>
{/* RIGHT COLUMN */}
<div className="flex-1 min-w-0">
{/* TITLE */}
<div className="text-lg font-bold text-gray-900 dark:text-white">
{result.name || <span>No Name</span>}
{result.start_year && <> ({result.start_year})</>}
</div> </div>
<div className="column">
{/* TOKENS */} <div className="is-size-3">
<div className="flex flex-wrap gap-2 mt-2"> {!isEmpty(result.name) ? (
{/* ISSUE COUNT */} result.name
{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="is-size-3">No Name</span>
<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>
)} )}
</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>
{/* ID */} <div className="control">
<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 className="tags has-addons">
<span className="pr-1 pt-1"> <span className="tag is-warning">
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4" /> {result.id}
</span> </span>
<span>{result.id}</span> </div>
</span> </div>
</div> </div>
{/* LINK */} <a href={result.api_detail_url}>
<a {result.api_detail_url}
href={result.api_detail_url} </a>
className="text-sm text-blue-500 underline mt-2 break-all" <p>
>
{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( {ellipsize(
convert(result.description, { convert(result.description, {
baseElements: { selectors: ["p", "div"] }, baseElements: {
selectors: ["p"],
},
}), }),
320, 320,
)} )}
</p> </p>
)} <button
{result.name ? ( className="button is-success is-light is-outlined mt-2"
<div className="mt-4 justify-self-end"> onClick={() => addToLibrary("comicvine", result)}
<PopoverButton >
content={`This will add ${result.count_of_issues} issues your wanted list.`} <i className="fa-solid fa-plus mr-2"></i> Want
clickHandler={() => </button>
addToWantedList({ </div>
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
) : null}
</div> </div>
</div> </div>
) );
); })}
})} </div>
</div> ) : (
) : ( <article className="message is-dark is-half">
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="message-body">
<article <p className="mb-2">
role="alert" <span className="tag is-medium is-info is-light">
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" Search the ComicVine database
> </span>
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
Search and add issues, series and trade paperbacks to your Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or library. Then, download them using the configured AirDC++ or
torrent clients. torrent clients.
</p> </p>
</div> </div>
</article> </article>
</div> )}
)} </div>
</section> </section>
</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

@@ -14,9 +14,19 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "GET", method: "GET",
}), }),
}); });
console.log(data);
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 +35,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,128 @@ 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>>({});
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-cols max-w-screen-xl mx-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => {
return (
<div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul className="pl-4 mt-2">
{map(item.children, (item, idx) => (
<li key={item.id} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
{/* Main Layout */} {/* content for settings */}
<div className="flex gap-8 px-12 py-6"> <div className="max-w-screen-xl">
{/* Sidebar */} <div className="content">
<div className="relative z-30"> {map(settingsContent, ({ id, content }) =>
<aside active === id ? content : null,
className="sticky top-6 w-72 max-h-[90vh] )}
rounded-2xl shadow-xl backdrop-blur-md </div>
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
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">
{settingObject.category.toUpperCase()}
</h3>
{!isUndefined(settingObject.children) && (
<ul>
{map(settingObject.children, (item, idx) => {
const isOpen = expanded[item.id];
return (
<li key={idx} className="mb-1">
<div
onClick={() => toggleExpanded(item.id)}
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 === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
<span
onClick={() => setActive(item.id.toString())}
className="flex-1"
>
{item.displayName}
</span>
{!isUndefined(item.children) && (
<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
onClick={() =>
setActive(subItem.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}
</a>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</aside>
</div> </div>
{/* Content */}
<main className="flex-1 px-2 py-2">
{settingsContent.map(({ id, content }) =>
active === id ? <div key={id}>{content}</div> : null,
)}
</main>
</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,87 +4,124 @@ 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 { isUndefined } from "lodash";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Volumes = (props): ReactElement => { export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes); // const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const { useEffect(() => {
data: volumes, // dispatch(
isSuccess, // searchIssue(
isError, // {
isLoading, // query: {},
} = useQuery({ // },
queryFn: async () => // {
await axios({ // pagination: {
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`, // size: 25,
method: "POST", // from: 0,
data: { // },
query: {}, // type: "volumes",
pagination: { // trigger: "volumesPage",
size: 25, // },
from: 0, // ),
}, // );
type: "volumes", }, []);
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
console.log(volumes);
const columnData = useMemo( const columnData = useMemo(
(): any => [ () => [
{ {
header: "Volume Details", header: "Volume Details",
id: "volumeDetails", id: "volumeDetails",
minWidth: 450, minWidth: 450,
accessorFn: (row) => row, accessorKey: "_source",
cell: (row): any => { cell: (row) => {
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="columns">
<Link to={`/volume/details/${comicObject._id}`}> <div className="column">
<Card <div className="comic-detail issue-metadata">
imageUrl={ <dl>
sourcedMetadata.comicvine.volumeInformation.image.small_url <dd>
} <div className="columns mt-2">
orientation={"cover-only"} <div className="">
hasDetails={false} <Card
/> imageUrl={
</Link> foo.sourcedMetadata.comicvine.volumeInformation
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3"> .image.thumb_url
<div className="text-xl mb-1 w-fit"> }
{sourcedMetadata.comicvine.volumeInformation.name} orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div> </div>
<p>
{ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
180,
)}
</p>
</div> </div>
</div> </div>
); );
}, },
}, },
{ {
header: "Other Details", header: "Download Status",
columns: [ columns: [
{ {
header: "Downloads", header: "Files",
accessorKey: "_source.acquisition.directconnect", accessorKey: "_source.acquisition.directconnect",
align: "right", align: "right",
cell: (props) => { cell: (props) => {
@@ -105,34 +142,12 @@ export const Volumes = (props): ReactElement => {
}, },
}, },
{ {
header: "Publisher", header: "Type",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation", id: "Air",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
}, },
{ {
header: "Issue Count", header: "Type",
accessorKey: id: "dcc",
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
const row = props.getValue();
return (
<div className="mt-5">
{/* issue count */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-6 h-6"></i>
</span>
<span className="text-lg text-slate-500 dark:text-slate-900">
{row}
</span>
</span>
</div>
);
},
}, },
], ],
}, },
@@ -140,45 +155,28 @@ export const Volumes = (props): ReactElement => {
[], [],
); );
return ( return (
<div> <section className="container">
<section className=""> <div className="section">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="header-area">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Volumes</h1>
<div className="sm:flex sm:items-center sm:justify-between"> </div>
<div className="text-center sm:text-left"> {!isUndefined(volumes.hits) && (
<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">
Browse your collection of volumes.
</p>
</div>
</div>
</div>
</header>
{isSuccess ? (
<div> <div>
<div className="library"> <div className="library">
<T2Table <T2Table
sourceData={volumes?.data.hits.hits} sourceData={volumes?.hits}
totalPages={volumes?.data.hits.hits.length} totalPages={volumes.hits.length}
paginationHandlers={{ paginationHandlers={{
nextPage: () => {}, nextPage: () => {},
previousPage: () => {}, previousPage: () => {},
}} }}
rowClickHandler={() => {}}
columns={columnData} columns={columnData}
/> />
</div> </div>
</div> </div>
) : null} )}
{isError ? ( </div>
<div>An error was encountered while retrieving volumes</div> </section>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
); );
}; };

View File

@@ -1,86 +1,31 @@
import React from "react"; import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { searchIssue } from "../../actions/fileops.actions";
import { gql, GraphQLClient } from "graphql-request"; import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
/** export const WantedComics = (props): ReactElement => {
* GraphQL client for interfacing with Moleculer Apollo server. // const wantedComics = useSelector(
*/ // (state: RootState) => state.fileOps.wantedComics,
const client = new GraphQLClient("http://localhost:3000/graphql"); // );
useEffect(() => {
/** // dispatch(
* GraphQL query to fetch wanted comics. // searchIssue(
*/ // {
const WANTED_COMICS_QUERY = gql` // query: {},
query { // },
wantedComics(limit: 25, offset: 0) { // {
total // pagination: {
comics // size: 25,
} // from: 0,
} // },
`; // type: "wanted",
// trigger: "wantedComicsPage"
/** // },
* 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"],
queryFn: async () => {
const res = await client.request<WantedComicsResponse>(
WANTED_COMICS_QUERY,
);
if (!res?.wantedComics?.comics) {
throw new Error("No comics returned");
}
return res.wantedComics;
},
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const columnData = [ const columnData = [
{ {
@@ -90,12 +35,8 @@ 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) => <MetadataPanel data={value.getValue()} />,
const row = value.getValue();
console.log("Comic row data:", row);
return row ? <MetadataPanel data={row} /> : null;
},
}, },
], ],
}, },
@@ -104,73 +45,132 @@ const WantedComics = (): JSX.Element => {
columns: [ columns: [
{ {
header: "Files", header: "Files",
accessorKey: "acquisition",
align: "right", align: "right",
accessorFn: (row: Comic) => cell: (props) => {
row?.acquisition?.directconnect?.downloads || [], const {
cell: (props: any) => { directconnect: { downloads },
const downloads = props.getValue(); } = props.getValue();
return downloads?.length > 0 ? ( return (
<span className="tag is-warning">{downloads.length}</span> <div
) : null; 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: "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> <section className="container">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="section">
<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="header-area">
<div className="sm:flex sm:items-center sm:justify-between"> <h1 className="title">Wanted Comics</h1>
<div className="text-center sm:text-left"> </div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl"> {!isEmpty(wantedComics) && (
Wanted Comics <div>
</h1> <div className="library">
<p className="mt-1.5 text-sm text-gray-500 dark:text-white"> <T2Table
Browse through comics you marked as "wanted." sourceData={wantedComics}
</p> totalPages={wantedComics.length}
columns={columnData}
paginationHandlers={{
nextPage: nextPage,
previousPage: previousPage,
}}
// rowClickHandler={navigateToComicDetail}
/>
{/* pagination controls */}
</div> </div>
</div> </div>
</div> )}
</header> </div>
{isLoading && (
<div className="animate-pulse p-4 space-y-4">
{Array.from({ length: 5 }).map((_, idx) => (
<div
key={idx}
className="h-24 bg-slate-300 dark:bg-slate-600 rounded-md"
/>
))}
</div>
)}
{isError && <div>Error fetching wanted comics. {error?.message}</div>}
{isSuccess && data?.comics?.length > 0 ? (
<T2Table
sourceData={data.comics}
totalPages={data.comics.length}
columns={columnData}
paginationHandlers={{}}
/>
) : isSuccess ? (
<div>No comics found.</div>
) : null}
</section> </section>
); );
}; };

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
export const Canvas = ({ data }) => { export const Canvas = (data) => {
const { colorHistogramData } = data; const { colorHistogramData } = data.data;
console.log(data);
const width = 559; const width = 559;
const height = 200; const height = 200;
const pixelRatio = window.devicePixelRatio; const pixelRatio = window.devicePixelRatio;
@@ -9,11 +10,7 @@ export const Canvas = ({ data }) => {
const canvas = useRef(null); const canvas = useRef(null);
useEffect(() => { useEffect(() => {
const context = canvas.current?.getContext("2d"); const context = canvas.current.getContext("2d");
if (!context) {
return;
}
context.scale(pixelRatio, pixelRatio); context.scale(pixelRatio, pixelRatio);
const guideHeight = 8; const guideHeight = 8;
const startY = height - guideHeight; const startY = height - guideHeight;
@@ -49,24 +46,18 @@ export const Canvas = ({ data }) => {
context.stroke(); context.stroke();
// Guide // Guide
context.strokeStyle = `rgb(${i}, ${i}, ${i})`; context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
context.beginPath(); context.beginPath();
context.moveTo(x, startY); context.moveTo(x, startY);
context.lineTo(x, height); context.lineTo(x, height);
context.closePath(); context.closePath();
context.stroke(); context.stroke();
} }
});
// Cleanup function
return () => {
// Perform cleanup actions here
};
}, [colorHistogramData, pixelRatio]);
const dw = Math.floor(pixelRatio * width); const dw = Math.floor(pixelRatio * width);
const dh = Math.floor(pixelRatio * height); const dh = Math.floor(pixelRatio * height);
const style = { width, height }; const style = { width, height };
return <canvas ref={canvas} width={dw} height={dh} style={style} />; return <canvas ref={canvas} width={dw} height={dh} style={style} />;
}; };

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,26 +83,24 @@ 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-fit 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 ? ( <div className="mt-2 px-2">
<div className="px-3 pt-3 mb-2"> <dl>
<dd className="text-sm text-slate-500 dark:text-black"> <div>
{props.title} <dd className="text-md text-slate-500 dark:text-black">
</dd> {props.title}
</div> </dd>
) : null} </div>
</dl>
{props.hasDetails ? ( {props.hasDetails && <>{props.children}</>}
<div className="px-2"> </div>
<>{props.children}</>
</div>
) : null}
</div> </div>
); );
@@ -140,31 +138,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,98 +0,0 @@
import React, { useRef, useState } from "react";
import { format } from "date-fns";
import FocusTrap from "focus-trap-react";
import { ClassNames, DayPicker } from "react-day-picker";
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
import styles from "react-day-picker/dist/style.module.css";
export const DatePickerDialog = (props) => {
const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const classNames: ClassNames = {
...styles,
head: "custom-head",
};
const buttonRef = useRef<HTMLButtonElement>(null);
const { x, y, reference, floating, strategy, refs, update } = useFloating({
placement: "bottom-end",
middleware: [offset(10), flip()],
strategy: "absolute",
});
const closePopper = () => {
setIsPopperOpen(false);
buttonRef.current?.focus();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
if (refs.reference.current && refs.floating.current) {
autoUpdate(refs.reference.current, refs.floating.current, update);
}
};
const handleDaySelect = (date) => {
setSelected(date);
if (date) {
setter(format(date, "yyyy/MM/dd"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={reference}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
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"
>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
ref={floating}
style={{
position: strategy,
zIndex: "999",
borderRadius: "10px",
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"
aria-label="DayPicker calendar"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
classNames={classNames}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

@@ -64,7 +64,7 @@ export const DnD = (data) => {
className="mt-2 mb-2" className="mt-2 mb-2"
onClick={(e) => data.onClickHandler(url)} onClick={(e) => data.onClickHandler(url)}
> >
<div className="box p-2 control-palette"> <div className="box p-2 pl-3 control-palette">
<span className="tag is-warning mr-2">{index}</span> <span className="tag is-warning mr-2">{index}</span>
<span className="icon is-small mr-2"> <span className="icon is-small mr-2">
<i className="fa-solid fa-vial"></i> <i className="fa-solid fa-vial"></i>

View File

@@ -8,6 +8,7 @@ export function Grid({ children, columns }) {
gridTemplateColumns: `repeat(${columns}, 200px)`, gridTemplateColumns: `repeat(${columns}, 200px)`,
columnGap: 1, columnGap: 1,
gridGap: 10, gridGap: 10,
padding: 10,
}} }}
> >
{children} {children}

View File

@@ -1,32 +1,19 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { Link } from "react-router-dom";
type IHeaderProps = { type IHeaderProps = {
headerContent: string; headerContent: string;
subHeaderContent: ReactElement; subHeaderContent: string;
iconClassNames: string; iconClassNames: string;
link?: string;
}; };
export const Header = (props: IHeaderProps): ReactElement => { export const Header = (props: IHeaderProps): ReactElement => {
return ( return (
<div className="mt-7"> <>
<div className=""> <h4 className="title is-4">
{props.link ? ( <i className={props.iconClassNames}></i> {props.headerContent}
<Link to={props.link}> </h4>
<span className="text-xl"> <p className="subtitle is-7">{props.subHeaderContent}</p>
<span className="underline"> </>
{props.headerContent}{" "}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
</Link>
) : (
<div className="text-xl">{props.headerContent}</div>
)}
<p className="">{props.subHeaderContent}</p>
</div>
</div>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { Card } from "../shared/Carda"; import { Card } from "../shared/Carda";
@@ -6,48 +7,26 @@ import { convert } from "html-to-text";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash"; import { find, isUndefined } from "lodash";
/** interface IMetadatPanelProps {
* Props for the MetadataPanel component. value: any;
*/ children: any;
interface MetadataPanelProps { imageStyle: any;
/** titleStyle: any;
* Comic metadata object passed into the panel. tagsStyle: any;
*/ containerStyle: any;
data: any;
/**
* Optional custom styling for the cover image.
*/
imageStyle?: React.CSSProperties;
/**
* Optional custom styling for the title section.
*/
titleStyle?: React.CSSProperties;
} }
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
/**
* MetadataPanel component
*
* Displays structured comic metadata based on the best available source
* (raw file data, ComicVine, or League of Comic Geeks).
*
* @component
* @param {MetadataPanelProps} props
* @returns {ReactElement}
*/
export const MetadataPanel = (props: MetadataPanelProps): ReactElement => {
const { const {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,
sourcedMetadata: { comicvine, locg }, sourcedMetadata: { comicvine, locg },
} = props.data; } = props.data;
const { issueName, url, objectReference } = determineCoverFile({ const { issueName, url, objectReference } = determineCoverFile({
comicvine, comicvine,
locg, locg,
rawFileDetails, rawFileDetails,
}); });
const metadataContentPanel = [ const metadataContentPanel = [
{ {
name: "rawFileDetails", name: "rawFileDetails",
@@ -64,31 +43,41 @@ 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">
</span> <i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
<i className="icon-[solar--database-bold-duotone] w-4 h-4 mr-1 opacity-70" />
{prettyBytes(rawFileDetails.fileSize)}
</span>
{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">
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5 pr-1 pt-1" />
</span> </span>
)}
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</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(rawFileDetails.fileSize)}
</span>
</span>
</dd> </dd>
</dl> </dl>
), ),
@@ -96,56 +85,79 @@ 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"> <div className="field is-grouped is-grouped-multiline">
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md"> <div className="control">
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4 mr-1 opacity-70" /> <span className="tags">
{comicvine.volumeInformation.start_year} <span
</span> className="tag is-success is-light has-text-weight-semibold"
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md"> style={props.tagsStyle}
<i className="icon-[solar--book-bold-duotone] w-4 h-4 mr-1 opacity-70" /> >
{comicvine.volumeInformation.count_of_issues} issues {comicvine.volumeInformation.start_year}
</span> </span>
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md"> <span
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70" /> className="tag is-success is-light"
ID: {comicvine.id} style={props.tagsStyle}
</span> >
</dd> {comicvine.volumeInformation.count_of_issues}
</dl> </span>
) </span>
); </div>
}, <div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
</dd>
</dl>
),
}, },
{ {
name: "locg", name: "locg",
content: () => ( content: () => (
@@ -156,22 +168,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 +194,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

@@ -52,12 +52,12 @@ export const Navbar2 = (): ReactElement => {
</li> </li>
<li> <li>
<Link <a
to="/volumes"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75" className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
> >
Volumes Volumes
</Link> </a>
</li> </li>
<li> <li>
@@ -68,14 +68,6 @@ export const Navbar2 = (): ReactElement => {
Downloads Downloads
</a> </a>
</li> </li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</ul> </ul>
</nav> </nav>
@@ -98,7 +90,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 +109,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,7 @@
import React, { ReactElement, useMemo, useState } from "react"; import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -8,19 +11,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 +62,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 +72,43 @@ 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"> {/* Search bar */}
{/* Search bar */} <div className="flex flex-row gap-2 justify-between mt-5">
{tableOptions.children} <SearchBar />
{/* Pagination controls */} {/* pagination controls */}
<div className="text-sm text-gray-800 dark:text-slate-200"> <div>
<div className="mb-1"> Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} <p>{totalPages} comics in all</p>
</div> {/* Prev/Next buttons */}
<p className="text-xs text-gray-600 dark:text-slate-400"> <div className="inline-flex flex-row mt-4 mb-4">
{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 +123,36 @@ 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,
};
export default T2Table; export default T2Table;

View File

@@ -0,0 +1,48 @@
export const comicModel = {
name: "",
type: "",
import: {
isImported: false,
},
userAddedMetadata: {
tags: [],
},
comicStructure: {
cover: {
thumb: "http://thumb",
medium: "http://medium",
large: "http://large",
},
collection: {
publishDate: "",
type: "", // issue, series, trade paperback
metadata: {
publisher: "",
issueNumber: "",
description: "",
synopsis: "",
team: {},
},
},
},
sourcedMetadata: {
comicvine: {},
shortboxed: {},
gcd: {},
},
rawFileDetails: {
fileName: "",
path: "",
extension: "",
},
acquisition: {
release: {},
torrent: {
magnet: "",
tracker: "",
status: "",
},
usenet: {},
},
};

View File

@@ -8,6 +8,7 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
options.apiPath options.apiPath
); );
}; };
console.log(import.meta);
export const CORS_PROXY_SERVER_URI = hostURIBuilder({ export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http", protocol: "http",
@@ -90,24 +91,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

@@ -0,0 +1 @@
export const SUPPORTED_COMIC_ARCHIVES = [".cbz", ".cbr"];

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

@@ -7,16 +7,12 @@ 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 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 VolumeDetails from "./components/VolumeDetail/VolumeDetail";
import WantedComics from "./components/WantedComics/WantedComics";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -26,7 +22,6 @@ const router = createBrowserRouter([
element: <App />, element: <App />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ path: "/", element: <Dashboard /> },
{ path: "dashboard", element: <Dashboard /> }, { path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> }, { path: "settings", element: <Settings /> },
{ {
@@ -38,10 +33,6 @@ const router = createBrowserRouter([
element: <ComicDetailContainer />, element: <ComicDetailContainer />,
}, },
{ path: "import", element: <Import path={"./comics"} /> }, { path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> },
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
{ path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> },
], ],
}, },
]); ]);
@@ -49,5 +40,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,258 @@
import { create } from "zustand"; import { create } from "zustand";
import io, { Socket } from "socket.io-client"; import { isEmpty, isNil, isUndefined } from "lodash";
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints"; import { SOCKET_BASE_URI } from "../constants/endpoints";
import { isNil } from "lodash"; import { produce } from "immer";
import { toast } from "react-toastify"; import AirDCPPSocket from "../services/DcppSearchService";
import "react-toastify/dist/ReactToastify.min.css"; 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: {
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"] });
});
/**
* 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");
}
console.log("connected?", getState());

View File

11194
yarn.lock

File diff suppressed because it is too large Load Diff