Compare commits
33 Commits
dark-mode-
...
react-19-u
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2a455d73 | |||
| b83d40d7e6 | |||
|
|
0615d08e7d | ||
| 21a127509b | |||
|
|
e92b86792e | ||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 | |||
|
|
f57bd35cd4 | ||
|
|
56c9fb03ac | ||
|
|
26c9c1e562 | ||
|
|
51e18c65d1 | ||
|
|
b82d5fd350 | ||
| 9a3ccba719 | |||
|
|
c89e4af328 | ||
| df4cecedf0 | |||
| 3dee53f33f | |||
| ef05dee600 | |||
| 2b6bce4731 | |||
| fc4c5c61e2 | |||
| ae04260f69 | |||
|
|
ad5fc0b8b3 | ||
| c03f706e9d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ 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/
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -3,17 +3,21 @@ LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
|||||||
|
|
||||||
WORKDIR /threetwo
|
WORKDIR /threetwo
|
||||||
|
|
||||||
|
# Copy package.json and lock file first to leverage Docker cache
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY yarn.lock ./
|
COPY yarn.lock ./
|
||||||
COPY nodemon.json ./
|
|
||||||
COPY jsdoc.json ./
|
|
||||||
|
|
||||||
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
|
||||||
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
|
|
||||||
RUN yarn --ignore-engines
|
|
||||||
|
|
||||||
|
# Install build dependencies necessary for native modules
|
||||||
|
RUN apk --no-cache add g++ make libpng-dev git python3 autoconf automake libjpeg-turbo-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||||
|
|
||||||
|
# Install node modules
|
||||||
|
RUN yarn install --ignore-engines
|
||||||
|
# Explicitly install sass
|
||||||
|
RUN yarn add -D sass
|
||||||
|
# Copy the rest of the application
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
ENTRYPOINT [ "npm", "start" ]
|
# Use yarn start if you want to stick with yarn, or change to npm start if you prefer npm
|
||||||
|
ENTRYPOINT [ "yarn", "start" ]
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
|
|||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||

|
#### Dashboard
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
#### Issue View
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### DC++ Search
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Import
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Comic Vine Matching, Metadata Scraping
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 🦄 Early Development Support Channel
|
### 🦄 Early Development Support Channel
|
||||||
|
|
||||||
@@ -28,7 +39,8 @@ 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-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -43,20 +55,18 @@ 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)
|
||||||
|
|
||||||
|
|||||||
47
package.json
47
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "threetwo",
|
"name": "threetwo",
|
||||||
"version": "0.0.2",
|
"version": "0.1.0",
|
||||||
"description": "ThreeTwo! A comic book curator.",
|
"description": "ThreeTwo! A good comic book curator.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"typings": "server/index.js",
|
"typings": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
"@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",
|
||||||
"@redux-devtools/extension": "^3.2.5",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@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",
|
||||||
@@ -27,49 +29,51 @@
|
|||||||
"@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.3.4",
|
"axios": "^1.7.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.17.1",
|
"express": "^4.20.0",
|
||||||
"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",
|
||||||
"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.2.0",
|
"react": "^19.0.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.6.0",
|
"react-day-picker": "^8.10.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.0.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": "^6.9.0",
|
"react-router": "^7.1.5",
|
||||||
"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",
|
||||||
"reapop": "^4.2.1",
|
"react-toastify": "^10.0.5",
|
||||||
"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.0.5",
|
"vite": "^5.4.12",
|
||||||
"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"
|
||||||
@@ -93,8 +97,8 @@
|
|||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/lodash": "^4.14.168",
|
"@types/lodash": "^4.14.168",
|
||||||
"@types/node": "^14.14.34",
|
"@types/node": "^14.14.34",
|
||||||
"@types/react": "^18.0.28",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/react-redux": "^7.1.25",
|
"@types/react-redux": "^7.1.25",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
@@ -108,7 +112,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.17.1",
|
"express": "^4.20.0",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jest": "^29.6.3",
|
"jest": "^29.6.3",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
@@ -117,10 +121,13 @@
|
|||||||
"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.69.5",
|
"sass": "^1.77.0",
|
||||||
"storybook": "^7.3.2",
|
"storybook": "^7.3.2",
|
||||||
"tailwindcss": "^3.3.5",
|
"tailwindcss": "^3.4.1",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
screenshots/CVMatching.jpg
Normal file
BIN
screenshots/CVMatching.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
BIN
screenshots/ComicDetail.jpg
Normal file
BIN
screenshots/ComicDetail.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 449 KiB |
BIN
screenshots/DC++Searching.jpg
Normal file
BIN
screenshots/DC++Searching.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
BIN
screenshots/Dashboard.jpg
Normal file
BIN
screenshots/Dashboard.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/Import.jpg
Normal file
BIN
screenshots/Import.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
BIN
screenshots/Library.jpg
Normal file
BIN
screenshots/Library.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 849 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router";
|
||||||
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";
|
||||||
|
|
||||||
export const App = (): ReactElement => {
|
export const App = (): ReactElement => {
|
||||||
@@ -8,6 +9,7 @@ export const App = (): ReactElement => {
|
|||||||
<>
|
<>
|
||||||
<Navbar2 />
|
<Navbar2 />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<ToastContainer stacked hideProgressBar />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||||
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
|
|
||||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
@@ -8,8 +7,9 @@ import { difference } from "../../shared/utils/object.utils";
|
|||||||
import { isEmpty, isNil, map } from "lodash";
|
import { isEmpty, isNil, map } from "lodash";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
|
||||||
interface IAcquisitionPanelProps {
|
interface IAcquisitionPanelProps {
|
||||||
query: any;
|
query: any;
|
||||||
@@ -21,17 +21,9 @@ interface IAcquisitionPanelProps {
|
|||||||
export const AcquisitionPanel = (
|
export const AcquisitionPanel = (
|
||||||
props: IAcquisitionPanelProps,
|
props: IAcquisitionPanelProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
const {
|
const { socketIOInstance } = useStore(
|
||||||
airDCPPSocketInstance,
|
|
||||||
airDCPPClientConfiguration,
|
|
||||||
airDCPPSessionInformation,
|
|
||||||
airDCPPDownloadTick,
|
|
||||||
} = useStore(
|
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
socketIOInstance: state.socketIOInstance,
|
||||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
|
||||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
|
||||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -40,27 +32,64 @@ export const AcquisitionPanel = (
|
|||||||
hub_urls: string[] | undefined | null;
|
hub_urls: string[] | undefined | null;
|
||||||
priority: PriorityEnum;
|
priority: PriorityEnum;
|
||||||
}
|
}
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
// Add other properties as needed
|
||||||
|
slots: any;
|
||||||
|
type: any;
|
||||||
|
users: any;
|
||||||
|
name: string;
|
||||||
|
dupe: Boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (searchQuery) => {
|
||||||
|
// Use the already connected socket instance to emit events
|
||||||
|
socketIOInstance.emit("initiateSearch", searchQuery);
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
});
|
||||||
/**
|
/**
|
||||||
* Get the hubs list from an AirDCPP Socket
|
* Get the hubs list from an AirDCPP Socket
|
||||||
*/
|
*/
|
||||||
const { data: hubs } = useQuery({
|
const { data: hubs } = useQuery({
|
||||||
queryKey: ["hubs"],
|
queryKey: ["hubs"],
|
||||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
queryFn: async () =>
|
||||||
|
await axios({
|
||||||
|
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
host: settings?.data.directConnect?.client?.host,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||||
});
|
});
|
||||||
const { comicObjectId } = props;
|
const { comicObjectId } = props;
|
||||||
const issueName = props.query.issue.name || "";
|
const issueName = props.query.issue.name || "";
|
||||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
|
|
||||||
const [dcppQuery, setDcppQuery] = useState({});
|
const [dcppQuery, setDcppQuery] = useState({});
|
||||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<
|
||||||
|
SearchResult[]
|
||||||
|
>([]);
|
||||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
||||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
||||||
// Pre-populate the search input with the search string, so that
|
// Pre-populate the search input with the search string, so that
|
||||||
// All the user has to do is hit "Search AirDC++"
|
// all the user has to do is hit "Search AirDC++" to perform a search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// AirDC++ search query
|
// AirDC++ search query
|
||||||
const dcppSearchQuery = {
|
const dcppSearchQuery = {
|
||||||
@@ -68,7 +97,7 @@ export const AcquisitionPanel = (
|
|||||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
},
|
},
|
||||||
hub_urls: map(hubs, (item) => item.value),
|
hub_urls: map(hubs?.data, (item) => item.value),
|
||||||
priority: 5,
|
priority: 5,
|
||||||
};
|
};
|
||||||
setDcppQuery(dcppSearchQuery);
|
setDcppQuery(dcppSearchQuery);
|
||||||
@@ -79,81 +108,49 @@ export const AcquisitionPanel = (
|
|||||||
* @param {SearchData} data - a SearchData query
|
* @param {SearchData} data - a SearchData query
|
||||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||||
*/
|
*/
|
||||||
const search = async (data: SearchData, ADCPPSocket: any) => {
|
const search = async (searchData: any) => {
|
||||||
try {
|
setAirDCPPSearchResults([]);
|
||||||
if (!ADCPPSocket.isConnected()) {
|
socketIOInstance.emit("call", "socket.search", {
|
||||||
await ADCPPSocket();
|
query: searchData,
|
||||||
}
|
config: {
|
||||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
protocol: `ws`,
|
||||||
setAirDCPPSearchStatus(true);
|
// hostname: `192.168.1.119:5600`,
|
||||||
|
hostname: `127.0.0.1:5600`,
|
||||||
// We want to get notified about every new result in order to make the user experience better
|
username: `user`,
|
||||||
await ADCPPSocket.addListener(
|
password: `pass`,
|
||||||
`search`,
|
},
|
||||||
"search_result_added",
|
});
|
||||||
async (groupedResult) => {
|
|
||||||
// ...add the received result in the UI
|
|
||||||
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
|
|
||||||
setAirDCPPSearchResults((state) => [...state, groupedResult]);
|
|
||||||
},
|
|
||||||
instance.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
|
|
||||||
await ADCPPSocket.addListener(
|
|
||||||
`search`,
|
|
||||||
"search_result_updated",
|
|
||||||
async (groupedResult) => {
|
|
||||||
// ...update properties of the existing result in the UI
|
|
||||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
|
||||||
(bundle) => bundle.result.id === groupedResult.result.id,
|
|
||||||
);
|
|
||||||
const updatedState = [...airDCPPSearchResults];
|
|
||||||
if (
|
|
||||||
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
|
|
||||||
) {
|
|
||||||
updatedState[bundleToUpdateIndex] = groupedResult;
|
|
||||||
}
|
|
||||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
|
||||||
},
|
|
||||||
instance.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
|
|
||||||
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
|
|
||||||
await ADCPPSocket.addListener(
|
|
||||||
`search`,
|
|
||||||
"search_hub_searches_sent",
|
|
||||||
async (searchInfo) => {
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
// Check the number of received results (in real use cases we should know that even without calling the API)
|
|
||||||
const currentInstance = await ADCPPSocket.get(
|
|
||||||
`search/${instance.id}`,
|
|
||||||
);
|
|
||||||
setAirDCPPSearchInstance(currentInstance);
|
|
||||||
setAirDCPPSearchInfo(searchInfo);
|
|
||||||
console.log("Asdas", airDCPPSearchInfo);
|
|
||||||
if (currentInstance.result_count === 0) {
|
|
||||||
// ...nothing was received, show an informative message to the user
|
|
||||||
console.log("No more search results.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The search can now be considered to be "complete"
|
|
||||||
// If there's an "in progress" indicator in the UI, that could also be disabled here
|
|
||||||
setAirDCPPSearchInstance(instance);
|
|
||||||
setAirDCPPSearchStatus(false);
|
|
||||||
},
|
|
||||||
instance.id,
|
|
||||||
);
|
|
||||||
// Finally, perform the actual search
|
|
||||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socketIOInstance.on("searchResultAdded", ({ result }: any) => {
|
||||||
|
setAirDCPPSearchResults((previousState) => {
|
||||||
|
const exists = previousState.some((item) => result.id === item.id);
|
||||||
|
if (!exists) {
|
||||||
|
return [...previousState, result];
|
||||||
|
}
|
||||||
|
return previousState;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socketIOInstance.on("searchResultUpdated", ({ result }: any) => {
|
||||||
|
// ...update properties of the existing result in the UI
|
||||||
|
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||||
|
(bundle) => bundle.id === result.id,
|
||||||
|
);
|
||||||
|
const updatedState = [...airDCPPSearchResults];
|
||||||
|
if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) {
|
||||||
|
updatedState[bundleToUpdateIndex] = result;
|
||||||
|
}
|
||||||
|
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketIOInstance.on("searchInitiated", (data) => {
|
||||||
|
setAirDCPPSearchInstance(data.instance);
|
||||||
|
});
|
||||||
|
socketIOInstance.on("searchesSent", (data) => {
|
||||||
|
setAirDCPPSearchInfo(data.searchInfo);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to download a bundle associated with a search result from AirDC++
|
* Method to download a bundle associated with a search result from AirDC++
|
||||||
* @param {Number} searchInstanceId - description
|
* @param {Number} searchInstanceId - description
|
||||||
@@ -162,7 +159,7 @@ export const AcquisitionPanel = (
|
|||||||
* @param {String} name - description
|
* @param {String} name - description
|
||||||
* @param {Number} size - description
|
* @param {Number} size - description
|
||||||
* @param {any} type - description
|
* @param {any} type - description
|
||||||
* @param {any} ADCPPSocket - description
|
* @param {any} config - description
|
||||||
* @returns {void} - description
|
* @returns {void} - description
|
||||||
*/
|
*/
|
||||||
const download = async (
|
const download = async (
|
||||||
@@ -172,48 +169,22 @@ export const AcquisitionPanel = (
|
|||||||
name: String,
|
name: String,
|
||||||
size: Number,
|
size: Number,
|
||||||
type: any,
|
type: any,
|
||||||
ADCPPSocket: any,
|
config: any,
|
||||||
): void => {
|
): Promise<void> => {
|
||||||
try {
|
socketIOInstance.emit(
|
||||||
if (!ADCPPSocket.isConnected()) {
|
"call",
|
||||||
await ADCPPSocket.connect();
|
"socket.download",
|
||||||
}
|
{
|
||||||
let bundleDBImportResult = {};
|
searchInstanceId,
|
||||||
const downloadResult = await ADCPPSocket.post(
|
resultId,
|
||||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
comicObjectId,
|
||||||
);
|
name,
|
||||||
|
size,
|
||||||
if (!isNil(downloadResult)) {
|
type,
|
||||||
bundleDBImportResult = await axios({
|
config,
|
||||||
method: "POST",
|
},
|
||||||
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
|
(data: any) => console.log(data),
|
||||||
headers: {
|
);
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
bundleId: downloadResult.bundle_info.id,
|
|
||||||
comicObjectId,
|
|
||||||
name,
|
|
||||||
size,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// dispatch({
|
|
||||||
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
|
||||||
// downloadResult,
|
|
||||||
// bundleDBImportResult,
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// dispatch({
|
|
||||||
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
|
||||||
// comicBookDetail: bundleDBImportResult.data,
|
|
||||||
// IMS_inProgress: false,
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const getDCPPSearchResults = async (searchQuery) => {
|
const getDCPPSearchResults = async (searchQuery) => {
|
||||||
const manualQuery = {
|
const manualQuery = {
|
||||||
@@ -221,274 +192,290 @@ export const AcquisitionPanel = (
|
|||||||
pattern: `${searchQuery.issueName}`,
|
pattern: `${searchQuery.issueName}`,
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
},
|
},
|
||||||
hub_urls: map(hubs, (hub) => hub.hub_url),
|
hub_urls: [hubs?.data[0].hub_url],
|
||||||
priority: 5,
|
priority: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
search(manualQuery, airDCPPSocketInstance);
|
search(manualQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
// download via AirDC++
|
|
||||||
const downloadDCPPResult = useCallback(
|
|
||||||
(searchInstanceId, resultId, name, size, type) => {
|
|
||||||
// dispatch(
|
|
||||||
// downloadAirDCPPItem(
|
|
||||||
// searchInstanceId,
|
|
||||||
// resultId,
|
|
||||||
// props.comicObjectId,
|
|
||||||
// name,
|
|
||||||
// size,
|
|
||||||
// type,
|
|
||||||
// airDCPPConfiguration.airDCPPState.socket,
|
|
||||||
// {
|
|
||||||
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
|
||||||
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// this is to update the download count badge on the downloads tab
|
|
||||||
// dispatch(
|
|
||||||
// getBundlesForComic(
|
|
||||||
// props.comicObjectId,
|
|
||||||
// airDCPPConfiguration.airDCPPState.socket,
|
|
||||||
// {
|
|
||||||
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
|
||||||
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="comic-detail columns">
|
<div className="mt-5 mb-3">
|
||||||
{!isEmpty(airDCPPSocketInstance) ? (
|
{!isEmpty(hubs?.data) ? (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={getDCPPSearchResults}
|
onSubmit={getDCPPSearchResults}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
issueName,
|
issueName,
|
||||||
}}
|
}}
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
<form
|
<form onSubmit={handleSubmit}>
|
||||||
onSubmit={handleSubmit}
|
<Field name="issueName">
|
||||||
className="column is-three-quarters"
|
{({ input, meta }) => {
|
||||||
>
|
return (
|
||||||
<div className="box search">
|
<div className="max-w-fit">
|
||||||
<div className="columns">
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||||
<Field name="issueName">
|
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||||
{({ input, meta }) => {
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
return (
|
|
||||||
<div className="column is-two-thirds">
|
|
||||||
<input
|
|
||||||
{...input}
|
|
||||||
className="input main-search-bar is-medium"
|
|
||||||
placeholder="Type an issue/volume name"
|
|
||||||
/>
|
|
||||||
<span className="help is-clearfix is-light is-info"></span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<input
|
||||||
}}
|
{...input}
|
||||||
</Field>
|
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||||
|
placeholder="Type an issue/volume name"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="column">
|
<button
|
||||||
<button
|
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
type="submit"
|
type="submit"
|
||||||
className={
|
>
|
||||||
airDCPPSearchStatus
|
<div className="flex flex-row">
|
||||||
? "button is-loading is-warning"
|
Search DC++
|
||||||
: "button is-success is-light"
|
<div className="h-5 w-5 ml-2">
|
||||||
}
|
<img
|
||||||
>
|
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||||
<span className="icon is-small">
|
className="h-5 w-5"
|
||||||
<img src="/src/client/assets/img/airdcpp_logo.svg" />
|
/>
|
||||||
</span>
|
</div>
|
||||||
<span className="airdcpp-text">Search on AirDC++</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
<article
|
||||||
<article className="message is-info">
|
role="alert"
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||||
AirDC++ is not configured. Please configure it in{" "}
|
>
|
||||||
<code>Settings > AirDC++ > Connection</code>.
|
No AirDC++ hub configured. Please configure it in{" "}
|
||||||
</div>
|
<code>Settings > AirDC++ > Hubs</code>.
|
||||||
</article>
|
</article>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* configured hub */}
|
||||||
|
{!isEmpty(hubs?.data) && (
|
||||||
|
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
{hubs && hubs?.data[0].hub_url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* AirDC++ search instance details */}
|
{/* AirDC++ search instance details */}
|
||||||
{!isNil(airDCPPSearchInstance) &&
|
{!isNil(airDCPPSearchInstance) &&
|
||||||
!isEmpty(airDCPPSearchInfo) &&
|
!isEmpty(airDCPPSearchInfo) &&
|
||||||
!isNil(hubs) && (
|
!isNil(hubs) && (
|
||||||
<div className="columns">
|
<div className="flex flex-row gap-3 my-5 font-hasklig">
|
||||||
<div className="column is-one-quarter is-size-7">
|
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
<div className="card">
|
<dl>
|
||||||
<div className="card-content">
|
<dt>
|
||||||
<dl>
|
<div className="mb-1">
|
||||||
<dt>
|
{hubs?.data.map((value, idx: string) => (
|
||||||
<div className="tags mb-1">
|
<span className="tag is-warning" key={idx}>
|
||||||
{hubs.map((value, idx) => (
|
{value.identity.name}
|
||||||
<span className="tag is-warning" key={idx}>
|
|
||||||
{value.identity.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</dt>
|
|
||||||
<dt>
|
|
||||||
Query:
|
|
||||||
<span className="has-text-weight-semibold">
|
|
||||||
{airDCPPSearchInfo.query.pattern}
|
|
||||||
</span>
|
</span>
|
||||||
</dt>
|
))}
|
||||||
<dd>
|
</div>
|
||||||
Extensions:
|
</dt>
|
||||||
<span className="has-text-weight-semibold">
|
|
||||||
{airDCPPSearchInfo.query.extensions.join(", ")}
|
<dt>
|
||||||
</span>
|
Query:
|
||||||
</dd>
|
<span className="has-text-weight-semibold">
|
||||||
<dd>
|
{airDCPPSearchInfo.query.pattern}
|
||||||
File type:
|
</span>
|
||||||
<span className="has-text-weight-semibold">
|
</dt>
|
||||||
{airDCPPSearchInfo.query.file_type}
|
<dd>
|
||||||
</span>
|
Extensions:
|
||||||
</dd>
|
<span className="has-text-weight-semibold">
|
||||||
</dl>
|
{airDCPPSearchInfo.query.extensions.join(", ")}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</dd>
|
||||||
|
<dd>
|
||||||
|
File type:
|
||||||
|
<span className="has-text-weight-semibold">
|
||||||
|
{airDCPPSearchInfo.query.file_type}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="column is-one-quarter is-size-7">
|
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
<div className="card">
|
<dl>
|
||||||
<div className="card-content">
|
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
|
||||||
<dl>
|
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
|
||||||
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
|
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
|
||||||
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
|
</dl>
|
||||||
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AirDC++ results */}
|
{/* AirDC++ results */}
|
||||||
<div className="columns">
|
<div className="">
|
||||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||||
<div className="column">
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
|
||||||
<table className="table">
|
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
<th>Type</th>
|
Name
|
||||||
<th>Slots</th>
|
</th>
|
||||||
<th>Actions</th>
|
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Slots
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
{map(
|
||||||
return (
|
airDCPPSearchResults,
|
||||||
<tr
|
({ dupe, type, name, id, slots, users, size }, idx) => {
|
||||||
key={idx}
|
return (
|
||||||
className={
|
<tr
|
||||||
!isNil(result.dupe) ? "dupe-search-result" : ""
|
key={idx}
|
||||||
}
|
className={
|
||||||
>
|
!isNil(dupe)
|
||||||
<td>
|
? "bg-gray-100 dark:bg-gray-700"
|
||||||
<p className="mb-2">
|
: "w-fit text-sm"
|
||||||
{result.type.id === "directory" ? (
|
}
|
||||||
<i className="fas fa-folder"></i>
|
>
|
||||||
) : null}
|
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
||||||
{ellipsize(result.name, 70)}
|
<p className="mb-2">
|
||||||
</p>
|
{type.id === "directory" ? (
|
||||||
|
<i className="fas fa-folder"></i>
|
||||||
|
) : null}
|
||||||
|
{ellipsize(name, 70)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<dl>
|
<dl>
|
||||||
<dd>
|
<dd>
|
||||||
<div className="tags">
|
<div className="inline-flex flex-row gap-2">
|
||||||
{!isNil(result.dupe) ? (
|
{!isNil(dupe) ? (
|
||||||
<span className="tag is-warning">Dupe</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">
|
||||||
) : null}
|
<span className="pr-1 pt-1">
|
||||||
<span className="tag is-light is-info">
|
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
||||||
{result.users.user.nicks}
|
</span>
|
||||||
</span>
|
|
||||||
{result.users.user.flags.map((flag, idx) => (
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
<span className="tag is-light" key={idx}>
|
Dupe
|
||||||
{flag}
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Nicks */}
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--user-rounded-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{users.user.nicks}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
{/* Flags */}
|
||||||
</div>
|
{users.user.flags.map((flag, idx) => (
|
||||||
</dd>
|
<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">
|
||||||
</dl>
|
<span className="pr-1 pt-1">
|
||||||
</td>
|
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
|
||||||
<td>
|
</span>
|
||||||
<span className="tag is-light is-info">
|
|
||||||
{result.type.id === "directory"
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
? "directory"
|
{flag}
|
||||||
: result.type.str}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
))}
|
||||||
<td>
|
</div>
|
||||||
<div className="tags has-addons">
|
</dd>
|
||||||
<span className="tag is-success">
|
</dl>
|
||||||
{result.slots.free} free
|
</td>
|
||||||
|
<td>
|
||||||
|
{/* Extension */}
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{type.str}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="tag is-light">
|
</td>
|
||||||
{result.slots.total}
|
<td className="px-2">
|
||||||
|
{/* Slots */}
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{slots.total} slots; {slots.free} free
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="px-2">
|
||||||
<td>
|
<button
|
||||||
<button
|
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-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-light is-success"
|
onClick={() =>
|
||||||
onClick={() =>
|
download(
|
||||||
download(
|
airDCPPSearchInstance.id,
|
||||||
airDCPPSearchInstance.id,
|
id,
|
||||||
result.id,
|
comicObjectId,
|
||||||
comicObjectId,
|
name,
|
||||||
result.name,
|
size,
|
||||||
result.size,
|
type,
|
||||||
result.type,
|
{
|
||||||
airDCPPSocketInstance,
|
protocol: `ws`,
|
||||||
)
|
hostname: `192.168.1.119:5600`,
|
||||||
}
|
username: `admin`,
|
||||||
>
|
password: `password`,
|
||||||
<span className="icon">
|
},
|
||||||
<i className="fas fa-file-download"></i>
|
)
|
||||||
</span>
|
}
|
||||||
<span>Download </span>
|
>
|
||||||
</button>
|
<span className="text-xs">Download</span>
|
||||||
</td>
|
<span className="w-5 h-5">
|
||||||
</tr>
|
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
|
||||||
);
|
</span>
|
||||||
})}
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
<div className="">
|
||||||
<article className="message is-info">
|
<article
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
role="alert"
|
||||||
<p>
|
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"
|
||||||
The default search term is an auto-detected title; you may
|
>
|
||||||
need to change it to get better matches if the auto-detected
|
<div>
|
||||||
one doesn't work.
|
The default search term is an auto-detected title; you may need
|
||||||
</p>
|
to change it to get better matches if the auto-detected one
|
||||||
|
doesn't work.
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="message is-warning">
|
<article
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
role="alert"
|
||||||
<p className="content">
|
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"
|
||||||
Searching via <strong>AirDC++</strong> is still in
|
>
|
||||||
<strong>alpha</strong>. Some searches may take arbitrarily
|
<div>
|
||||||
long, or may not work at all. Searches from <code>ADCS</code>
|
Searching via <strong>AirDC++</strong> is still in{" "}
|
||||||
hubs are more reliable than <code>NMDCS</code> ones.
|
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
||||||
</p>
|
or may not work at all. Searches from{" "}
|
||||||
|
<code className="font-hasklig">ADCS</code> hubs are more
|
||||||
|
reliable than <code className="font-hasklig">NMDCS</code> ones.
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,94 +1,23 @@
|
|||||||
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
import React, { ReactElement } from "react";
|
||||||
import React, { ReactElement, useCallback } from "react";
|
import Select from "react-select";
|
||||||
import Select, { components } from "react-select";
|
|
||||||
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
|
|
||||||
import { refineQuery } from "filename-parser";
|
|
||||||
|
|
||||||
export const Menu = (props): ReactElement => {
|
export const Menu = (props): ReactElement => {
|
||||||
const { data } = props;
|
const {
|
||||||
const { setSlidingPanelContentId, setVisible } = props.handlers;
|
filteredActionOptions,
|
||||||
const openDrawerWithCVMatches = useCallback(() => {
|
customStyles,
|
||||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
handleActionSelection,
|
||||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
Placeholder,
|
||||||
|
} = 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>
|
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||||
<i className="fa-solid fa-list"></i> Actions
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Select An Action</div>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
styles={customStyles}
|
styles={customStyles}
|
||||||
|
|||||||
51
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
51
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ellipsize from "ellipsize";
|
||||||
|
import { map } from "lodash";
|
||||||
|
|
||||||
|
export const AirDCPPBundles = (props) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||||
|
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Filename
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Download Time
|
||||||
|
</th>
|
||||||
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
|
Bundle ID
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{map(props.data, (bundle) => (
|
||||||
|
<tr key={bundle.id} className="text-sm">
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||||
|
<span className="text-xs">{ellipsize(bundle.target, 88)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
{prettyBytes(bundle.size)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
{dayjs
|
||||||
|
.unix(bundle.time_finished)
|
||||||
|
.format("h:mm on ddd, D MMM, YYYY")}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
|
<span className="tag is-warning">{bundle.id}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
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);
|
||||||
|
|
||||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
interface AsyncSelectPaginateProps {
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -38,11 +44,4 @@ export const AsyncSelectPaginate = (props): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AsyncSelectPaginate.propTypes = {
|
|
||||||
metronResource: PropTypes.string.isRequired,
|
|
||||||
placeholder: PropTypes.string,
|
|
||||||
value: PropTypes.object,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AsyncSelectPaginate;
|
export default AsyncSelectPaginate;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, ReactElement, useCallback } from "react";
|
import React, { useState, ReactElement, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||||
|
|
||||||
@@ -12,10 +12,12 @@ import { Menu } from "./ActionMenu/Menu";
|
|||||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||||
import AcquisitionPanel from "./AcquisitionPanel";
|
import AcquisitionPanel from "./AcquisitionPanel";
|
||||||
|
import TorrentSearchPanel from "./TorrentSearchPanel";
|
||||||
import DownloadsPanel from "./DownloadsPanel";
|
import DownloadsPanel from "./DownloadsPanel";
|
||||||
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||||
|
|
||||||
import { isEmpty, isUndefined, isNil } from "lodash";
|
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||||
|
import { components } from "react-select";
|
||||||
import { RootState } from "threetwo-ui-typings";
|
import { RootState } from "threetwo-ui-typings";
|
||||||
|
|
||||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||||
@@ -27,6 +29,10 @@ 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";
|
||||||
|
|
||||||
type ComicDetailProps = {};
|
type ComicDetailProps = {};
|
||||||
/**
|
/**
|
||||||
@@ -56,20 +62,8 @@ 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();
|
||||||
@@ -87,6 +81,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
// );
|
// );
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// overridden <SlidingPanel> with some styles
|
||||||
|
const StyledSlidingPanel = styled(SlidingPane)`
|
||||||
|
background: #ccc;
|
||||||
|
`;
|
||||||
const afterOpenModal = useCallback((things) => {
|
const afterOpenModal = useCallback((things) => {
|
||||||
// references are now sync'd and can be accessed.
|
// references are now sync'd and can be accessed.
|
||||||
// subtitle.style.color = "#f00";
|
// subtitle.style.color = "#f00";
|
||||||
@@ -102,57 +100,183 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
CVMatches: {
|
CVMatches: {
|
||||||
content: (props) => (
|
content: (props) => (
|
||||||
<>
|
<>
|
||||||
{/* <div className="card search-criteria-card">
|
<div>
|
||||||
<div className="card-content">
|
<ComicVineSearchForm data={rawFileDetails} />
|
||||||
<ComicVineSearchForm data={rawFileDetails} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
|
|
||||||
{inferredMetadata.issue ? (
|
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||||
<div className="ml-3">
|
<p className="">Searching for:</p>
|
||||||
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
|
{inferredMetadata.issue ? (
|
||||||
<span className="tag"> # {inferredMetadata.issue.number} </span>
|
<>
|
||||||
</div>
|
<span className="">{inferredMetadata.issue.name} </span>
|
||||||
) : null}
|
<span className=""> # {inferredMetadata.issue.number} </span>
|
||||||
{!comicVineAPICallProgress ? (
|
</>
|
||||||
<ComicVineMatchPanel
|
) : null}
|
||||||
props={{
|
</div>
|
||||||
comicVineSearchQueryObject,
|
<ComicVineMatchPanel
|
||||||
comicVineAPICallProgress,
|
props={{
|
||||||
comicVineSearchResults,
|
comicVineMatches,
|
||||||
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 />,
|
content: () => <EditMetadataPanel data={rawFileDetails} />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
const fetchComicVineMatches = async (
|
||||||
|
searchPayload,
|
||||||
|
issueSearchQuery,
|
||||||
|
seriesSearchQuery,
|
||||||
|
) => {
|
||||||
|
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) => {
|
||||||
|
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) => match);
|
||||||
|
}
|
||||||
|
const scoredMatches = matches.sort((a, b) => b.score - a.score);
|
||||||
|
setComicVineMatches(scoredMatches);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action event handlers
|
||||||
|
const openDrawerWithCVMatches = () => {
|
||||||
|
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||||
|
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||||
|
|
||||||
|
if (!isUndefined(rawFileDetails)) {
|
||||||
|
issueSearchQuery = refineQuery(rawFileDetails.name);
|
||||||
|
} else if (!isEmpty(comicvine)) {
|
||||||
|
issueSearchQuery = refineQuery(comicvine.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-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Match on ComicVine</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const editLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Edit Metadata</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const deleteLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-2">
|
||||||
|
<div className="w-6 h-6">
|
||||||
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>Delete Comic</div>
|
||||||
|
</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(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 = {
|
||||||
|
menu: (base) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
placeholder: (base) => ({
|
||||||
|
...base,
|
||||||
|
color: "black",
|
||||||
|
}),
|
||||||
|
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
singleValue: (base) => ({
|
||||||
|
...base,
|
||||||
|
paddingTop: "0.4rem",
|
||||||
|
}),
|
||||||
|
control: (base) => ({
|
||||||
|
...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.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.cover);
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||||
|
|
||||||
const { issueName, url } = determineCoverFile({
|
const { issueName, url } = determineCoverFile({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
@@ -172,7 +296,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Volume Information",
|
name: "Volume Information",
|
||||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
icon: (
|
||||||
|
<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,
|
||||||
@@ -181,28 +307,30 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "ComicInfo.xml",
|
name: "ComicInfo.xml",
|
||||||
icon: <i className="fa-solid fa-code"></i>,
|
icon: (
|
||||||
|
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||||
|
),
|
||||||
content: (
|
content: (
|
||||||
<div className="columns" key={2}>
|
<div key={2}>
|
||||||
<div className="column is-three-quarters">
|
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
shouldShow: !isEmpty(comicInfo),
|
shouldShow: !isEmpty(comicInfo),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
icon: <i className="fa-regular fa-file-archive"></i>,
|
icon: (
|
||||||
|
<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: <></>,
|
content: <ArchiveOperations data={data.data} key={3} />,
|
||||||
/*
|
|
||||||
<ArchiveOperations data={data.data} key={3} /> */
|
|
||||||
shouldShow: areRawFileDetailsAvailable,
|
shouldShow: areRawFileDetailsAvailable,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
icon: <i className="fa-solid fa-circle-nodes"></i>,
|
icon: (
|
||||||
|
<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
|
||||||
@@ -217,26 +345,37 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
icon: <i className="fa-solid fa-droplet"></i>,
|
icon: (
|
||||||
|
<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: <>Torrents</>,
|
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
|
||||||
shouldShow: true,
|
shouldShow: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
icon: null,
|
name: "Downloads",
|
||||||
name: !isEmpty(data.data) ? (
|
icon: (
|
||||||
<span className="download-tab-name">Downloads</span>
|
<>
|
||||||
) : (
|
{acquisition?.directconnect?.downloads?.length +
|
||||||
"Downloads"
|
acquisition?.torrent.length}
|
||||||
),
|
</>
|
||||||
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -271,7 +410,21 @@ 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" } }}
|
||||||
@@ -293,22 +446,15 @@ 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}
|
||||||
acquisition={acquisition}
|
downloadCount={acquisition?.directconnect?.downloads?.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SlidingPane
|
<StyledSlidingPanel
|
||||||
isOpen={visible}
|
isOpen={visible}
|
||||||
onRequestClose={() => setVisible(false)}
|
onRequestClose={() => setVisible(false)}
|
||||||
title={"Comic Vine Search Matches"}
|
title={"Comic Vine Search Matches"}
|
||||||
@@ -316,7 +462,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
>
|
>
|
||||||
{slidingPanelContentId !== "" &&
|
{slidingPanelContentId !== "" &&
|
||||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
</SlidingPane>
|
</StyledSlidingPanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import React, { ReactElement } from "react";
|
||||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
import { useParams } from "react-router";
|
||||||
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";
|
||||||
@@ -14,7 +12,7 @@ export const ComicDetailContainer = (): ReactElement | null => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [],
|
queryKey: ["comicBookMetadata"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
await axios({
|
await axios({
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||||
@@ -24,11 +22,6 @@ export const ComicDetailContainer = (): ReactElement | null => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
console.log(comicBookDetailData);
|
|
||||||
useEffect(() => {
|
|
||||||
// dispatch(getComicBookDetailById(comicObjectId));
|
|
||||||
// dispatch(getSettings());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
isError && <>Error</>;
|
isError && <>Error</>;
|
||||||
|
|||||||
@@ -1,119 +1,118 @@
|
|||||||
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 { isUndefined } from "lodash";
|
import { isEmpty, isUndefined } from "lodash";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
export const ComicVineDetails = (props): ReactElement => {
|
import { convert } from "html-to-text";
|
||||||
|
|
||||||
|
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="column is-half">
|
<div className="text-slate-500 dark:text-gray-400">
|
||||||
<div className="comic-detail comicvine-metadata">
|
<div className="">
|
||||||
<dl>
|
<div>
|
||||||
<dt>ComicVine Metadata</dt>
|
<div className="flex flex-row gap-4">
|
||||||
<dd className="is-size-7">
|
<div className="min-w-fit">
|
||||||
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
<Card
|
||||||
</dd>
|
imageUrl={data.volumeInformation.image.thumb_url}
|
||||||
|
orientation={"cover-only"}
|
||||||
<dd>
|
hasDetails={false}
|
||||||
<div className="columns mt-2">
|
// cardContainerStyle={{ maxWidth: 200 }}
|
||||||
<div className="column is-2">
|
/>
|
||||||
<Card
|
</div>
|
||||||
imageUrl={data.volumeInformation.image.thumb_url}
|
<div className="flex flex-col gap-5">
|
||||||
orientation={"vertical"}
|
<div className="flex flex-row">
|
||||||
hasDetails={false}
|
<div>
|
||||||
// cardContainerStyle={{ maxWidth: 200 }}
|
{/* Title */}
|
||||||
/>
|
<div>
|
||||||
</div>
|
<div className="text-lg">{data.name}</div>
|
||||||
<div className="column is-10">
|
<div className="text-sm">
|
||||||
<dl>
|
Is a part of{" "}
|
||||||
<dt>
|
<span className="has-text-info">
|
||||||
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
|
{data.volumeInformation.name}
|
||||||
</dt>
|
</span>
|
||||||
<dd>
|
|
||||||
Is a part of{" "}
|
|
||||||
<span className="has-text-info">
|
|
||||||
{data.volumeInformation.name}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dd>
|
|
||||||
Published by
|
|
||||||
<span className="has-text-weight-semibold">
|
|
||||||
{" "}
|
|
||||||
{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>
|
||||||
</dd>
|
</div>
|
||||||
</dl>
|
|
||||||
|
{/* Comicvine metadata */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-md">ComicVine Metadata</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Last scraped on{" "}
|
||||||
|
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
ComicVine Issue ID
|
||||||
|
<span>{data.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publisher details */}
|
||||||
|
<div className="ml-8">
|
||||||
|
Published by{" "}
|
||||||
|
<span>{data.volumeInformation.publisher.name}</span>
|
||||||
|
<div>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{data.issue_number && (
|
||||||
|
<div className="">
|
||||||
|
<span>Issue Number</span>
|
||||||
|
<span>{data.issue_number}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUndefined(
|
||||||
|
detectIssueTypes(data.volumeInformation.description),
|
||||||
|
) ? (
|
||||||
|
<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>
|
||||||
</dd>
|
</div>
|
||||||
</dl>
|
</div>
|
||||||
</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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,22 +2,41 @@ 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 {
|
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||||
comicObjectId,
|
const { comicvine } = useStore(
|
||||||
comicVineSearchQueryObject,
|
useShallow((state) => ({
|
||||||
comicVineAPICallProgress,
|
comicvine: state.comicvine,
|
||||||
comicVineSearchResults,
|
})),
|
||||||
} = comicVineData.props;
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="search-results-container">
|
<div>
|
||||||
{!isEmpty(comicVineSearchResults) && (
|
{!isEmpty(comicVineMatches) ? (
|
||||||
<MatchResult
|
<MatchResult
|
||||||
matchData={comicVineSearchResults}
|
matchData={comicVineMatches}
|
||||||
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -34,83 +34,55 @@ export const ComicVineSearchForm = (data) => {
|
|||||||
validate={validate}
|
validate={validate}
|
||||||
render={({ handleSubmit }) => (
|
render={({ handleSubmit }) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<span className="field is-normal">
|
<span className="flex items-center">
|
||||||
<label className="label mb-2 is-size-5">Search Manually</label>
|
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
|
||||||
|
Override Search Query
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
</span>
|
</span>
|
||||||
<div className="field is-horizontal">
|
<label className="block py-1">Issue Name</label>
|
||||||
<div className="field-body">
|
<Field name="issueName">
|
||||||
<div className="field">
|
{(props) => (
|
||||||
<Field name="issueName">
|
<input
|
||||||
{(props) => (
|
{...props.input}
|
||||||
<p className="control is-expanded has-icons-left">
|
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"
|
||||||
<input
|
placeholder="Type the issue name"
|
||||||
{...props.input}
|
/>
|
||||||
className="input is-normal"
|
)}
|
||||||
placeholder="Type the issue name"
|
</Field>
|
||||||
/>
|
<div className="flex flex-row gap-4">
|
||||||
<span className="icon is-small is-left">
|
<div>
|
||||||
<i className="fas fa-journal-whills"></i>
|
<label className="block py-1">Number</label>
|
||||||
</span>
|
<Field name="issueNumber">
|
||||||
</p>
|
{(props) => (
|
||||||
)}
|
<input
|
||||||
</Field>
|
{...props.input}
|
||||||
</div>
|
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"
|
||||||
|
placeholder="#"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<label className="block py-1">Year</label>
|
||||||
<div className="field is-horizontal">
|
<Field name="issueYear">
|
||||||
<div className="field-body">
|
{(props) => (
|
||||||
<div className="field">
|
<input
|
||||||
<Field name="issueNumber">
|
{...props.input}
|
||||||
{(props) => (
|
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"
|
||||||
<p className="control has-icons-left">
|
placeholder="1984"
|
||||||
<input
|
/>
|
||||||
{...props.input}
|
)}
|
||||||
className="input is-normal"
|
</Field>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="flex justify-end mt-5">
|
||||||
<div className="field-body">
|
<button
|
||||||
<div className="field">
|
type="submit"
|
||||||
<div className="control">
|
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"
|
||||||
<button
|
>
|
||||||
type="submit"
|
Search
|
||||||
className="button is-success is-light is-outlined is-small"
|
</button>
|
||||||
>
|
|
||||||
<span className="icon">
|
|
||||||
<i className="fas fa-search"></i>
|
|
||||||
</span>
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,103 +1,140 @@
|
|||||||
import React, { useEffect, useContext, ReactElement } from "react";
|
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
||||||
import { getBundlesForComic } from "../../actions/airdcpp.actions";
|
|
||||||
import { RootState } from "threetwo-ui-typings";
|
import { RootState } from "threetwo-ui-typings";
|
||||||
import { isEmpty, isNil, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import prettyBytes from "pretty-bytes";
|
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||||
import dayjs from "dayjs";
|
import { TorrentDownloads } from "./TorrentDownloads";
|
||||||
import ellipsize from "ellipsize";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
|
TORRENT_JOB_SERVICE_BASE_URI,
|
||||||
|
SOCKET_BASE_URI,
|
||||||
|
} from "../../constants/endpoints";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
interface IDownloadsPanelProps {
|
interface IDownloadsPanelProps {
|
||||||
data: any;
|
key: number;
|
||||||
comicObjectId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadsPanel = (
|
export const DownloadsPanel = (
|
||||||
props: IDownloadsPanelProps,
|
props: IDownloadsPanelProps,
|
||||||
): ReactElement | null => {
|
): ReactElement | null => {
|
||||||
// const bundles = useSelector((state: RootState) => {
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
// return state.airdcpp.bundles;
|
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||||
// });
|
const [torrentDetails, setTorrentDetails] = useState([]);
|
||||||
//
|
const [activeTab, setActiveTab] = useState("directconnect");
|
||||||
// // AirDCPP Socket initialization
|
const { socketIOInstance } = useStore(
|
||||||
// const userSettings = useSelector((state: RootState) => state.settings.data);
|
useShallow((state: any) => ({
|
||||||
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
socketIOInstance: state.socketIOInstance,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
// React to torrent progress data sent over websockets
|
||||||
airDCPPState: { socket, settings },
|
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
|
||||||
} = airDCPPConfiguration;
|
const torrents = data.torrents
|
||||||
|
.flatMap(({ _id, details }) => {
|
||||||
|
if (_id === comicObjectId) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item !== undefined);
|
||||||
|
setTorrentDetails(torrents);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
/**
|
||||||
useEffect(() => {
|
* Query to fetch AirDC++ download bundles for a given comic resource Id
|
||||||
try {
|
* @param {string} {comicObjectId} - A mongo id that identifies a comic document
|
||||||
if (!isEmpty(userSettings)) {
|
*/
|
||||||
// dispatch(
|
const { data: bundles } = useQuery({
|
||||||
// getBundlesForComic(props.comicObjectId, socket, {
|
queryKey: ["bundles"],
|
||||||
// username: `${settings.directConnect.client.host.username}`,
|
queryFn: async () =>
|
||||||
// password: `${settings.directConnect.client.host.password}`,
|
await axios({
|
||||||
// }),
|
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||||
// );
|
method: "POST",
|
||||||
}
|
data: {
|
||||||
} catch (error) {
|
comicObjectId,
|
||||||
throw new Error(error);
|
config: {
|
||||||
}
|
protocol: `ws`,
|
||||||
}, [airDCPPConfiguration]);
|
hostname: `192.168.1.119:5600`,
|
||||||
|
username: `admin`,
|
||||||
|
password: `password`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: activeTab !== "" && activeTab === "directconnect",
|
||||||
|
});
|
||||||
|
|
||||||
const Bundles = (props) => {
|
// Call the scheduled job for fetching torrent data
|
||||||
return !isEmpty(props.data) ? (
|
// triggered by the active tab been set to "torrents"
|
||||||
<div className="column is-full">
|
const { data: torrentData } = useQuery({
|
||||||
<table className="table is-striped">
|
queryFn: () =>
|
||||||
<thead>
|
axios({
|
||||||
<tr>
|
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||||
<th>Filename</th>
|
method: "GET",
|
||||||
<th>Size</th>
|
params: {
|
||||||
<th>Download Time</th>
|
trigger: activeTab,
|
||||||
<th>Bundle ID</th>
|
},
|
||||||
</tr>
|
}),
|
||||||
</thead>
|
queryKey: [activeTab],
|
||||||
<tbody>
|
enabled: activeTab !== "" && activeTab === "torrents",
|
||||||
{map(props.data, (bundle) => (
|
});
|
||||||
<tr key={bundle.id}>
|
console.log(bundles);
|
||||||
<td>
|
return (
|
||||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
<div className="columns is-multiline">
|
||||||
<span className="is-size-7">{bundle.target}</span>
|
<div>
|
||||||
</td>
|
<div className="sm:hidden">
|
||||||
<td>{prettyBytes(bundle.size)}</td>
|
<label htmlFor="Download Type" className="sr-only">
|
||||||
<td>
|
Download Type
|
||||||
{dayjs
|
</label>
|
||||||
.unix(bundle.time_finished)
|
|
||||||
.format("h:mm on ddd, D MMM, YYYY")}
|
<select id="Tab" className="w-full rounded-md border-gray-200">
|
||||||
</td>
|
<option>DC++ Downloads</option>
|
||||||
<td>
|
<option>Torrents</option>
|
||||||
<span className="tag is-warning">{bundle.id}</span>
|
</select>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
))}
|
<div className="hidden sm:block">
|
||||||
</tbody>
|
<nav className="flex gap-6" aria-label="Tabs">
|
||||||
</table>
|
<a
|
||||||
|
href="#"
|
||||||
|
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||||
|
activeTab === "directconnect"
|
||||||
|
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
|
||||||
|
: "dark:text-slate-400 text-slate-800"
|
||||||
|
}`}
|
||||||
|
aria-current="page"
|
||||||
|
onClick={() => setActiveTab("directconnect")}
|
||||||
|
>
|
||||||
|
DC++ Downloads
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||||
|
activeTab === "torrents"
|
||||||
|
? "bg-slate-200 text-slate-800"
|
||||||
|
: "dark:text-slate-400 text-slate-800"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("torrents")}
|
||||||
|
>
|
||||||
|
Torrents
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="column is-full"> {"No Downloads Found"} </div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return !isNil(props.data) ? (
|
{activeTab === "torrents" ? (
|
||||||
<>
|
<TorrentDownloads data={torrentDetails} />
|
||||||
<div className="columns is-multiline">
|
) : null}
|
||||||
{!isEmpty(socket) ? (
|
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
|
||||||
<Bundles data={bundles} />
|
<AirDCPPBundles data={bundles.data} />
|
||||||
) : (
|
) : (
|
||||||
<div className="column is-three-fifths">
|
"nutin"
|
||||||
<article className="message is-info">
|
)}
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
</div>
|
||||||
AirDC++ is not configured. Please configure it in{" "}
|
);
|
||||||
<code>Settings</code>.
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DownloadsPanel;
|
export default DownloadsPanel;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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
|
||||||
@@ -56,90 +58,59 @@ 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">
|
||||||
<div className="field">
|
<Field
|
||||||
<p className="control is-expanded has-icons-left">
|
name="issue_name"
|
||||||
<Field
|
component="input"
|
||||||
name="issue_name"
|
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"
|
||||||
component="input"
|
initialValue={data.name}
|
||||||
className="input"
|
placeholder={"Issue Name"}
|
||||||
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="field is-horizontal">
|
<div className="mt-4 flex flex-row gap-2">
|
||||||
<div className="field-label"></div>
|
<div>
|
||||||
<div className="field-body">
|
<div className="text-sm">Issue Number</div>
|
||||||
<div className="field">
|
<Field
|
||||||
<p className="control has-icons-left">
|
name="issue_number"
|
||||||
<Field
|
component="input"
|
||||||
name="issue_number"
|
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"
|
placeholder="Issue Number"
|
||||||
className="input"
|
/>
|
||||||
placeholder="Issue Number"
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
/>
|
</div>
|
||||||
<span className="icon is-small is-left">
|
<div>
|
||||||
<i className="fa-solid fa-hashtag"></i>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="help">Do not enter the first zero</p>
|
|
||||||
</div>
|
|
||||||
{/* year */}
|
{/* year */}
|
||||||
<div className="field">
|
<div className="text-sm">Issue Year</div>
|
||||||
<p className="control">
|
<Field
|
||||||
<Field
|
name="issue_year"
|
||||||
name="issue_year"
|
component="input"
|
||||||
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"
|
||||||
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="field is-horizontal">
|
<div className="mt-2">
|
||||||
<div className="field-label is-normal">
|
<label className="text-sm">Description</label>
|
||||||
<label className="label">Description</label>
|
<Field
|
||||||
</div>
|
name={"description"}
|
||||||
<div className="field-body">
|
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">
|
component={TextareaAutosizeAdapter}
|
||||||
<p className="control is-expanded has-icons-left">
|
placeholder={"Description"}
|
||||||
<Field
|
/>
|
||||||
name={"description"}
|
|
||||||
className="textarea"
|
|
||||||
component={TextareaAutosizeAdapter}
|
|
||||||
placeholder={"Description"}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr size="1" />
|
<hr size="1" />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback } from "react";
|
import React 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;
|
||||||
@@ -14,14 +15,24 @@ const handleBrokenImage = (e) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MatchResult = (props: MatchResultProps) => {
|
export const MatchResult = (props: MatchResultProps) => {
|
||||||
const applyCVMatch = useCallback(
|
const applyCVMatch = async (match, comicObjectId) => {
|
||||||
// (match, comicObjectId) => {
|
return await axios.request({
|
||||||
// dispatch(applyComicVineMatch(match, comicObjectId));
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||||
// },
|
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)) {
|
||||||
@@ -31,88 +42,90 @@ export const MatchResult = (props: MatchResultProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
|
||||||
return (
|
return (
|
||||||
<div className="search-result mb-4" key={idx}>
|
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
|
||||||
<div className="columns">
|
<div className="flex flex-row gap-4">
|
||||||
<div className="column is-one-fifth">
|
<div className="min-w-fit">
|
||||||
<img
|
<img
|
||||||
className="cover-image"
|
className="rounded-md"
|
||||||
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}
|
||||||
|
|
||||||
<div className="search-result-details column">
|
{/* score */}
|
||||||
<div className="is-size-5">{match.name}</div>
|
<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">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
<div className="field is-grouped is-grouped-multiline mt-1">
|
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
|
||||||
<div className="control">
|
</span>
|
||||||
<div className="tags has-addons">
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
<span className="tag">Number</span>
|
{parseInt(match.score, 10)}
|
||||||
<span className="tag is-primary">
|
</span>
|
||||||
{match.issue_number}
|
</span>
|
||||||
</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>
|
||||||
<div className="is-size-7">
|
<span className="flex flex-row gap-2 mb-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(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="vertical-line"></div>
|
<div className="flex flex-row gap-2 my-4 ml-10">
|
||||||
|
<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="cover-image"
|
className="rounded-md w-full"
|
||||||
onError={handleBrokenImage}
|
onError={handleBrokenImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="column">
|
<div className="">
|
||||||
<div className="is-size-6">{match.volume.name}</div>
|
<span>{match.volume.name}</span>
|
||||||
<div className="field is-grouped is-grouped-multiline mt-2">
|
<div className="text-sm">
|
||||||
<div className="control">
|
<p>
|
||||||
<div className="tags has-addons">
|
Total Issues:
|
||||||
<span className="tag">Total Issues</span>
|
{match.volumeInformation.results.count_of_issues}
|
||||||
<span className="tag is-warning">
|
</p>
|
||||||
{match.volumeInformation.results.count_of_issues}
|
<p>
|
||||||
</span>
|
Published by{" "}
|
||||||
</div>
|
{match.volumeInformation.results.publisher.name}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<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="columns">
|
<div className="flex justify-end">
|
||||||
<div className="column">
|
<button
|
||||||
<button
|
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
className="button is-normal is-outlined is-primary is-light is-pulled-right"
|
onClick={() => applyCVMatch(match, props.comicObjectId)}
|
||||||
onClick={() => applyCVMatch(match, props.comicObjectId)}
|
>
|
||||||
>
|
<span className="text-md">Apply Match</span>
|
||||||
<span className="icon is-size-5">
|
<span className="w-5 h-5">
|
||||||
<i className="fas fa-clipboard-check"></i>
|
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Apply Match</span>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,53 @@
|
|||||||
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";
|
||||||
|
|
||||||
export const RawFileDetails = (props): ReactElement => {
|
interface RawFileDetailsProps {
|
||||||
|
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 => {
|
||||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||||
props.data;
|
props.data;
|
||||||
const PaperClipIcon = () => <></>;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-2xl ml-5">
|
<div className="max-w-2xl ml-5">
|
||||||
<div className="px-4 sm:px-6">
|
<div className="px-4 sm:px-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">{rawFileDetails.name}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-5 sm:px-6">
|
<div className="px-4 py-5 sm:px-6">
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<dt className="text-sm font-medium text-gray-500">
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Raw File Details
|
Raw File Details
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||||
{rawFileDetails.containedIn +
|
{rawFileDetails.containedIn +
|
||||||
"/" +
|
"/" +
|
||||||
rawFileDetails.name +
|
rawFileDetails.name +
|
||||||
@@ -30,10 +55,10 @@ export const RawFileDetails = (props): ReactElement => {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<dt className="text-sm font-medium text-gray-500">
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Inferred Issue Metadata
|
Inferred Issue Metadata
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||||
Series Name: {inferredMetadata.issue.name}
|
Series Name: {inferredMetadata.issue.name}
|
||||||
{!isEmpty(inferredMetadata.issue.number) ? (
|
{!isEmpty(inferredMetadata.issue.number) ? (
|
||||||
<span className="tag is-primary is-light">
|
<span className="tag is-primary is-light">
|
||||||
@@ -43,11 +68,13 @@ export const RawFileDetails = (props): ReactElement => {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<dt className="text-sm font-medium text-gray-500">MIMEType</dt>
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
MIMEType
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||||
{/* File extension */}
|
{/* 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="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="pt-1">
|
||||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -56,11 +83,12 @@ export const RawFileDetails = (props): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
<dd className="text-sm text-gray-900"></dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<dt className="text-sm font-medium text-gray-500">File Size</dt>
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
File Size
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||||
{/* size */}
|
{/* 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="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">
|
||||||
@@ -74,62 +102,19 @@ export const RawFileDetails = (props): ReactElement => {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500">
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Import Details
|
Import Details
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||||
{format(parseISO(created_at), "h aaaa")}
|
{format(parseISO(created_at), "h aaaa")}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500">Attachments</dt>
|
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
Actions
|
||||||
<ul
|
</dt>
|
||||||
role="list"
|
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
|
||||||
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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,29 +124,3 @@ export const RawFileDetails = (props): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from "react";
|
import React, { ReactElement, useState } from "react";
|
||||||
import { isEmpty, isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
export const TabControls = (props): ReactElement => {
|
export const TabControls = (props): ReactElement => {
|
||||||
// const comicBookDetailData = useSelector(
|
const { filteredTabs, downloadCount } = props;
|
||||||
// (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">
|
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="-mb-px flex gap-6" aria-label="Tabs">
|
<nav className="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 border-b-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
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 ${
|
||||||
|
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(acquisition.directconnect) ? (
|
{id === 6 && !isNil(downloadCount) ? (
|
||||||
<span className="download-icon-labels">
|
<span className="inline-flex flex-row">
|
||||||
<i className="fa-solid fa-download"></i>
|
{/* download count */}
|
||||||
<span className="tag downloads-count is-info is-light">
|
<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">
|
||||||
{acquisition.directconnect.downloads.length}
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{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>
|
||||||
|
|||||||
@@ -1,42 +1,134 @@
|
|||||||
import React, { ReactElement, useCallback, useState } from "react";
|
import React, { ReactElement, useCallback, useEffect, 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";
|
||||||
|
|
||||||
export const ArchiveOperations = (props): ReactElement => {
|
export const ArchiveOperations = (props): 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 { socketIOInstance } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
socketIOInstance: state.socketIOInstance,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
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([]);
|
const [currentImage, setCurrentImage] = useState([]);
|
||||||
|
const [uncompressedArchive, setUncompressedArchive] = useState([]);
|
||||||
|
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
|
||||||
|
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||||
|
useState(false);
|
||||||
|
const constructImagePaths = (data): Array<string> => {
|
||||||
|
return data?.map((path: string) =>
|
||||||
|
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen to the uncompression complete event and orchestrate the final payload
|
||||||
|
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
|
||||||
|
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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) => {
|
||||||
|
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 = {
|
const contentForSlidingPanel = {
|
||||||
@@ -44,13 +136,13 @@ export const ArchiveOperations = (props): ReactElement => {
|
|||||||
content: () => {
|
content: () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<pre className="is-size-7">{currentImage}</pre>
|
<pre className="text-sm">{currentImage}</pre>
|
||||||
{!isEmpty(imageAnalysisResult) ? (
|
{!isEmpty(imageAnalysisResult) ? (
|
||||||
<pre className="is-size-7 p-2 mt-3">
|
<pre className="p-2 mt-3">
|
||||||
<Canvas data={imageAnalysisResult} />
|
<Canvas data={imageAnalysisResult} />
|
||||||
</pre>
|
</pre>
|
||||||
) : null}
|
) : null}
|
||||||
<pre className="is-size-7 mt-3">
|
<pre className="font-hasklig mt-3 text-sm">
|
||||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,54 +154,81 @@ export const ArchiveOperations = (props): ReactElement => {
|
|||||||
// sliding panel handlers
|
// sliding panel handlers
|
||||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||||
setSlidingPanelContentId("imageAnalysis");
|
setSlidingPanelContentId("imageAnalysis");
|
||||||
// dispatch(analyzeImage(imageFilePath));
|
analyzeImage(imageFilePath);
|
||||||
setCurrentImage(imageFilePath);
|
setCurrentImage(imageFilePath);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={2}>
|
<div key={2}>
|
||||||
<button
|
<article
|
||||||
className={
|
role="alert"
|
||||||
isComicBookExtractionInProgress
|
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"
|
||||||
? "button is-loading is-warning"
|
|
||||||
: "button is-warning"
|
|
||||||
}
|
|
||||||
onClick={unpackComicArchive}
|
|
||||||
>
|
>
|
||||||
<span className="icon is-small">
|
<div>
|
||||||
<i className="fa-solid fa-box-open"></i>
|
<p>You can perform several operations on your comic book archive.</p>
|
||||||
</span>
|
<p>
|
||||||
<span>Unpack comic archive</span>
|
Uncompressing, re-organizing the individual pages, then
|
||||||
</button>
|
re-compressing to a different format, for example.
|
||||||
<div className="columns">
|
</p>
|
||||||
<div className="mt-5">
|
<p>You can also analyze color histograms of pages.</p>
|
||||||
{!isEmpty(extractedComicBookArchive) ? (
|
</div>
|
||||||
|
</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={extractedComicBookArchive}
|
data={uncompressedArchive}
|
||||||
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}
|
||||||
|
|||||||
@@ -4,52 +4,59 @@ 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="comicInfo-metadata">
|
<div className="flex md:w-4/5 lg:w-78">
|
||||||
<dl className="has-text-size-7">
|
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||||
<dd className="has-text-weight-medium">{json.series[0]}</dd>
|
<dt>
|
||||||
<dd className="mt-2 mb-2">
|
<p className="text-lg">{json.series[0]}</p>
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
</dt>
|
||||||
<div className="control">
|
<dd className="text-sm">
|
||||||
<span className="tags has-addons">
|
published by{" "}
|
||||||
<span className="tag">Pages</span>
|
<span className="underline">
|
||||||
<span className="tag is-warning is-light">
|
{json.publisher[0]}
|
||||||
{json.publisher[0]}
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
|
</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>
|
||||||
</div>
|
</dd>
|
||||||
<div className="control">
|
)}
|
||||||
<span className="tags has-addons">
|
<dd className="my-2">
|
||||||
<span className="tag">Issue #</span>
|
{/* Genre */}
|
||||||
<span className="tag is-warning is-light">
|
<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">
|
||||||
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
|
<span className="pr-1 pt-1">
|
||||||
</span>
|
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="control">
|
<span className="text-slate-500 dark:text-slate-900">
|
||||||
<span className="tags has-addons">
|
{json.genre[0]}
|
||||||
<span className="tag">Pages</span>
|
|
||||||
<span className="tag is-warning is-light">
|
|
||||||
{json.pagecount[0]}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
{!isUndefined(json.genre) && (
|
</dd>
|
||||||
<div className="control">
|
</span>
|
||||||
<span className="tags has-addons">
|
|
||||||
<span className="tag">Genre</span>
|
<dd className="my-1">
|
||||||
<span className="tag is-success is-light">
|
{/* Summary */}
|
||||||
{json.genre[0]}
|
{!isUndefined(json.summary) && (
|
||||||
</span>
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
</span>
|
{json.summary[0]}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
<dd>
|
<dd>
|
||||||
<span className="is-size-7">{json.notes[0]}</span>
|
{/* Notes */}
|
||||||
</dd>
|
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||||
<dd className="mt-1 mb-1">
|
{json.notes[0]}
|
||||||
{!isUndefined(json.summary) && json.summary[0]}
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
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}>
|
||||||
<div className="columns is-multiline">
|
<ComicVineDetails
|
||||||
<ComicVineDetails
|
data={data.sourcedMetadata.comicvine}
|
||||||
data={data.sourcedMetadata.comicvine}
|
updatedAt={data.updatedAt}
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
77
src/client/components/ComicDetail/TorrentDownloads.tsx
Normal file
77
src/client/components/ComicDetail/TorrentDownloads.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
202
src/client/components/ComicDetail/TorrentSearchPanel.tsx
Normal file
202
src/client/components/ComicDetail/TorrentSearchPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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;
|
||||||
@@ -1,29 +1,13 @@
|
|||||||
import React, { ReactElement, useEffect } from "react";
|
import React, { ReactElement } 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 {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 { Card } from "../shared/Carda";
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
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({
|
||||||
@@ -37,152 +21,61 @@ export const Dashboard = (): ReactElement => {
|
|||||||
limit: 5,
|
limit: 5,
|
||||||
sort: { updatedAt: "-1" },
|
sort: { updatedAt: "-1" },
|
||||||
},
|
},
|
||||||
predicate: { "acquisition.source.wanted": false },
|
predicate: {
|
||||||
|
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">
|
||||||
<section>
|
<PullList />
|
||||||
<h1>Dashboard</h1>
|
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||||
<div className="grid grid-cols-5 gap-6">
|
{/* Wanted comics */}
|
||||||
{recentComics?.data.docs.map(
|
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||||
(
|
{/* Library Statistics */}
|
||||||
{
|
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||||
_id,
|
{/* Volume groups */}
|
||||||
rawFileDetails,
|
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,113 +1,99 @@
|
|||||||
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">
|
||||||
<h4 className="title is-4">
|
<Header
|
||||||
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
|
headerContent="Your Library In Numbers"
|
||||||
</h4>
|
subHeaderContent={
|
||||||
<p className="subtitle is-7">A brief snapshot of your library.</p>
|
<span className="text-md">A brief snapshot of your library.</span>
|
||||||
<div className="columns is-multiline">
|
}
|
||||||
<div className="column is-narrow is-two-quarter">
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
<dl className="box">
|
/>
|
||||||
<dd className="is-size-4">
|
|
||||||
<span className="has-text-weight-bold">
|
<div className="mt-3">
|
||||||
{props.stats.totalDocuments}
|
<div className="flex flex-row gap-5">
|
||||||
</span>{" "}
|
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||||
files
|
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||||
|
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||||
|
{props.stats.totalDocuments} files
|
||||||
</dd>
|
</dd>
|
||||||
<dd className="is-size-4">
|
<dd>
|
||||||
Library size
|
<span className="text-2xl text-green-600">
|
||||||
<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) && (
|
||||||
<dd className="is-size-6">
|
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||||
<span className="has-text-weight-bold">
|
<span className="text-xl">
|
||||||
{props.stats.statistics[0].issues.length}
|
{props.stats.statistics[0].issues.length}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
tagged with ComicVine
|
tagged with ComicVine
|
||||||
</dd>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isUndefined(props.stats.statistics) &&
|
{!isUndefined(props.stats.statistics) &&
|
||||||
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
|
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
|
||||||
<dd className="is-size-6">
|
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||||
<span className="has-text-weight-bold">
|
<span className="text-xl">
|
||||||
{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">
|
||||||
ComicInfo.xml
|
with ComicInfo.xml
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 column is-one-quarter">
|
<div className="">
|
||||||
<dl className="box">
|
{!isUndefined(props.stats.statistics) &&
|
||||||
<dd className="is-size-6">
|
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||||
<span className="has-text-weight-bold"></span> Issues
|
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||||
</dd>
|
return (
|
||||||
<dd className="is-size-6">
|
<span
|
||||||
<span className="has-text-weight-bold">304</span> Volumes
|
key={idx}
|
||||||
</dd>
|
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 className="is-size-6">
|
>
|
||||||
{!isUndefined(props.stats.statistics) &&
|
{fileType.data.length} {fileType._id}
|
||||||
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
</span>
|
||||||
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
);
|
||||||
return (
|
})}
|
||||||
<span key={idx}>
|
</div>
|
||||||
<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="p-3 column is-two-fifths">
|
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||||
{/* 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="has-text-weight-bold">
|
<span className="">
|
||||||
{
|
{
|
||||||
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="has-text-weight-bold">
|
<span className="">
|
||||||
{
|
{
|
||||||
props.stats.statistics[0]
|
props.stats.statistics[0]
|
||||||
.publisherWithMostComicsInLibrary[0].count
|
.publisherWithMostComicsInLibrary[0].count
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</>
|
||||||
)}
|
)}
|
||||||
<dd className="is-size-6">
|
</div>
|
||||||
<span className="has-text-weight-bold">304</span> Volumes
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,160 +1,165 @@
|
|||||||
import { isNil, map } from "lodash";
|
import React, { ReactElement, useState } from "react";
|
||||||
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
|
import { map } from "lodash";
|
||||||
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 { Link } from "react-router";
|
||||||
import "slick-carousel/slick/slick.css";
|
|
||||||
import "slick-carousel/slick/slick-theme.css";
|
import axios from "axios";
|
||||||
import { Link } from "react-router-dom";
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PullList = ({ issues }: PullListProps): ReactElement => {
|
const http = rateLimiter(axios.create(), {
|
||||||
const dispatch = useDispatch();
|
maxRequests: 1,
|
||||||
useEffect(() => {
|
perMilliseconds: 1000,
|
||||||
dispatch(
|
maxRPS: 1,
|
||||||
getWeeklyPullList({
|
});
|
||||||
startDate: "2023-9-9",
|
const cachedAxios = setupCache(axios);
|
||||||
pageSize: "15",
|
export const PullList = (): ReactElement => {
|
||||||
currentPage: "1",
|
// datepicker
|
||||||
}),
|
const date = new Date();
|
||||||
);
|
const [inputValue, setInputValue] = useState<string>(
|
||||||
}, []);
|
format(date, "M-dd-yyyy"),
|
||||||
const addToLibrary = useCallback(
|
|
||||||
(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);
|
// keen slider
|
||||||
let sliderRef = createRef();
|
const [sliderRef, instanceRef] = useKeenSlider(
|
||||||
const settings = {
|
{
|
||||||
dots: false,
|
loop: true,
|
||||||
infinite: false,
|
slides: {
|
||||||
speed: 500,
|
origin: "auto",
|
||||||
slidesToShow: 5,
|
number: 15,
|
||||||
slidesToScroll: 5,
|
perView: 5,
|
||||||
initialSlide: 0,
|
spacing: 15,
|
||||||
responsive: [
|
|
||||||
{
|
|
||||||
breakpoint: 1024,
|
|
||||||
settings: {
|
|
||||||
slidesToShow: 3,
|
|
||||||
slidesToScroll: 3,
|
|
||||||
infinite: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
slideChanged() {
|
||||||
breakpoint: 600,
|
console.log("slide changed");
|
||||||
settings: {
|
|
||||||
slidesToShow: 2,
|
|
||||||
slidesToScroll: 2,
|
|
||||||
initialSlide: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: 480,
|
|
||||||
settings: {
|
|
||||||
slidesToShow: 1,
|
|
||||||
slidesToScroll: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
// add plugins here
|
||||||
],
|
],
|
||||||
};
|
);
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
importToDB(sourceName, { locg: locgMetadata });
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
sliderRef.slickNext();
|
// sliderRef.slickNext();
|
||||||
};
|
};
|
||||||
const previous = () => {
|
const previous = () => {
|
||||||
sliderRef.slickPrev();
|
// sliderRef.slickPrev();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<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">
|
||||||
|
Pull List aggregated for the week from{" "}
|
||||||
|
<span className="underline">
|
||||||
|
<a href="https://www.tfaw.com/comics/new-releases.html">
|
||||||
|
Things From Another World
|
||||||
|
</a>
|
||||||
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
|
link="/pull-list/all/"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-row gap-5 mb-3">
|
||||||
{/* select week */}
|
{/* select week */}
|
||||||
<div className="control">
|
<div className="flex flex-row gap-4 my-3">
|
||||||
<div className="select is-small">
|
<Form
|
||||||
<select>
|
onSubmit={() => {}}
|
||||||
<option>Select Week</option>
|
render={({ handleSubmit }) => (
|
||||||
<option>With options</option>
|
<form>
|
||||||
</select>
|
<div className="flex flex-col gap-2">
|
||||||
</div>
|
{/* week selection for pull list */}
|
||||||
</div>
|
<DatePickerDialog
|
||||||
{/* See all pull list issues */}
|
inputValue={inputValue}
|
||||||
<div className="control">
|
setter={setInputValue}
|
||||||
<Link to={"/pull-list/all/"}>
|
/>
|
||||||
<button className="button is-small">View all issues</button>
|
{inputValue && (
|
||||||
</Link>
|
<div className="text-sm">
|
||||||
</div>
|
Showing pull list for{" "}
|
||||||
<div className="field has-addons">
|
<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">
|
{inputValue}
|
||||||
<button className="button is-rounded is-small" onClick={previous}>
|
</span>
|
||||||
<i className="fa-solid fa-caret-left"></i>
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="control">
|
</form>
|
||||||
<button className="button is-rounded is-small" onClick={next}>
|
)}
|
||||||
<i className="fa-solid fa-caret-right"></i>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider {...settings} ref={(c) => (sliderRef = c)}>
|
|
||||||
{!isNil(pullList) &&
|
{isSuccess && !isLoading && (
|
||||||
pullList &&
|
<div ref={sliderRef} className="keen-slider flex flex-row">
|
||||||
map(pullList, ({ issue }, idx) => {
|
{map(pullList?.data.result, (issue, idx) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<div key={idx} className="keen-slider__slide">
|
||||||
key={idx}
|
<Card
|
||||||
orientation={"vertical"}
|
orientation={"vertical-2"}
|
||||||
imageUrl={issue.cover}
|
imageUrl={issue.coverImageUrl}
|
||||||
hasDetails
|
hasDetails
|
||||||
title={ellipsize(issue.name, 18)}
|
title={ellipsize(issue.name, 25)}
|
||||||
cardContainerStyle={{
|
>
|
||||||
marginRight: 22,
|
<div className="px-1">
|
||||||
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
|
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
}}
|
{issue.publicationDate}
|
||||||
>
|
</span>
|
||||||
<div className="content">
|
<div className="flex flex-row justify-end">
|
||||||
<div className="control">
|
<button
|
||||||
<span className="tag">{issue.publisher}</span>
|
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => addToLibrary("locg", issue)}
|
||||||
|
>
|
||||||
|
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
</Card>
|
||||||
<button
|
</div>
|
||||||
className="button is-small is-success is-outlined is-light"
|
|
||||||
onClick={() => addToLibrary("locg", issue)}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-plus"></i> Want
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Slider>
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? <div>Loading...</div> : null}
|
||||||
|
{isError ? (
|
||||||
|
<div>An error occurred while retrieving the pull list.</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,53 +1,40 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
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 = {
|
||||||
comicBookCovers: any;
|
comics: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecentlyImported = ({
|
export const RecentlyImported = (
|
||||||
comicBookCovers,
|
comics: RecentlyImportedProps,
|
||||||
}: RecentlyImportedProps): ReactElement => {
|
): ReactElement => {
|
||||||
const breakpointColumnsObj = {
|
console.log(comics);
|
||||||
default: 5,
|
|
||||||
1100: 4,
|
|
||||||
700: 2,
|
|
||||||
600: 2,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<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-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 },
|
||||||
acquisition: {
|
inferredMetadata,
|
||||||
source: { name },
|
wanted: { source } = {},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
idx,
|
idx,
|
||||||
) => {
|
) => {
|
||||||
@@ -57,90 +44,88 @@ export const RecentlyImported = ({
|
|||||||
comicInfo,
|
comicInfo,
|
||||||
locg,
|
locg,
|
||||||
});
|
});
|
||||||
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||||
comicvine,
|
source,
|
||||||
comicInfo,
|
{
|
||||||
locg,
|
comicvine,
|
||||||
});
|
comicInfo,
|
||||||
const isComicBookMetadataAvailable =
|
locg,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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 (
|
||||||
<React.Fragment key={_id}>
|
<Card
|
||||||
<Card
|
orientation="vertical-2"
|
||||||
orientation={"vertical"}
|
key={idx}
|
||||||
imageUrl={url}
|
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||||
hasDetails
|
title={inferredMetadata.issue.name}
|
||||||
title={issueName ? titleElement : null}
|
hasDetails
|
||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div>
|
||||||
{/* Raw file presence */}
|
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||||
{isNil(rawFileDetails) && (
|
{/* Issue number */}
|
||||||
<span className="icon custom-icon is-small has-text-danger mr-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">
|
||||||
<img src="/src/client/assets/img/missing-file.svg" />
|
<span className="pr-1 pt-1">
|
||||||
|
<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) && (
|
||||||
<span className="icon custom-icon is-small has-text-danger">
|
<div mt-1>
|
||||||
<img
|
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||||
src="/src/client/assets/img/comicinfoxml.svg"
|
</div>
|
||||||
alt={"ComicInfo.xml file detected."}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{/* ComicVine metadata presence */}
|
{/* ComicVine metadata presence */}
|
||||||
{isComicBookMetadataAvailable && (
|
{isComicVineMetadataAvailable && (
|
||||||
<span className="icon custom-icon">
|
<span className="w-7 h-7">
|
||||||
<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>
|
||||||
</Card>
|
{/* Raw file presence */}
|
||||||
{/* metadata card */}
|
{isNil(rawFileDetails) && (
|
||||||
{!isNil(name) && (
|
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||||
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
|
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||||
<dd className="is-size-9">
|
</span>
|
||||||
<dl>
|
)}
|
||||||
<span className="icon custom-icon">
|
</div>
|
||||||
<img src={`/src/client/assets/img/${icon}`} />
|
</Card>
|
||||||
</span>
|
|
||||||
</dl>
|
|
||||||
<dl>
|
|
||||||
<span className="small-tag">
|
|
||||||
{ellipsize(issue, 15)}
|
|
||||||
</span>
|
|
||||||
</dl>
|
|
||||||
</dd>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</Masonry>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { map, unionBy } from "lodash";
|
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";
|
||||||
import Masonry from "react-masonry-css";
|
import Card from "../shared/Carda";
|
||||||
|
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();
|
||||||
@@ -19,44 +14,48 @@ export const VolumeGroups = (props): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="volumes-container mt-4">
|
<section>
|
||||||
<div className="content">
|
<Header
|
||||||
<a className="mb-1" onClick={navigateToVolumes}>
|
headerContent="Volumes"
|
||||||
<span className="is-size-4 has-text-weight-semibold">
|
subHeaderContent="Based on ComicVine Volume information"
|
||||||
<i className="fa-solid fa-layer-group"></i> Volumes
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
</span>
|
link={"/volumes"}
|
||||||
<span className="icon mt-1">
|
/>
|
||||||
<i className="fa-solid fa-angle-right"></i>
|
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||||
</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="stack" key={data._id}>
|
<div className="max-w-sm mx-auto" key={data._id}>
|
||||||
<img src={data.volumes.image.small_url} />
|
<Card
|
||||||
<div className="content">
|
orientation="vertical-2"
|
||||||
<div className="stack-title is-size-8">
|
key={data._id}
|
||||||
<Link to={`/volume/details/${data._id}`}>
|
imageUrl={data.volumes.image.small_url}
|
||||||
{ellipsize(data.volumes.name, 18)}
|
hasDetails
|
||||||
</Link>
|
>
|
||||||
</div>
|
<div className="py-3">
|
||||||
<div className="control">
|
<div className="text-sm">
|
||||||
<span className="tags has-addons">
|
<Link to={`/volume/details/${data._id}`}>
|
||||||
<span className="tag is-primary is-light">Issues</span>
|
{ellipsize(data.volumes.name, 48)}
|
||||||
<span className="tag">{data.volumes.count_of_issues}</span>
|
</Link>
|
||||||
|
</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>
|
||||||
</div>
|
</Card>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Masonry>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router";
|
||||||
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";
|
||||||
|
|
||||||
type WantedComicsListProps = {
|
type WantedComicsListProps = {
|
||||||
comics: any;
|
comics: any;
|
||||||
@@ -14,47 +14,26 @@ 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) => {
|
|
||||||
navigate(`/wanted/all`);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="content mt-6">
|
<Header
|
||||||
<a className="mb-1" onClick={navigateToWantedComics}>
|
headerContent="Wanted Comics"
|
||||||
<span className="is-size-4 has-text-weight-semibold">
|
subHeaderContent="Comics marked as wanted from various sources"
|
||||||
<i className="fa-solid fa-asterisk"></i> Wanted Comics
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
</span>
|
link={"/wanted"}
|
||||||
<span className="icon mt-1">
|
/>
|
||||||
<i className="fa-solid fa-angle-right"></i>
|
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<p className="subtitle is-7">
|
|
||||||
Comics marked as wanted from various sources.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Masonry
|
|
||||||
breakpointCols={breakpointColumnsObj}
|
|
||||||
className="recent-comics-container"
|
|
||||||
columnClassName="recent-comics-column"
|
|
||||||
>
|
|
||||||
{map(
|
{map(
|
||||||
comics,
|
comics,
|
||||||
({
|
({
|
||||||
_id,
|
_id,
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
|
wanted,
|
||||||
}) => {
|
}) => {
|
||||||
const isComicBookMetadataAvailable =
|
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||||
!isUndefined(comicvine) &&
|
|
||||||
!isUndefined(comicvine.volumeInformation);
|
|
||||||
const consolidatedComicMetadata = {
|
const consolidatedComicMetadata = {
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
comicvine,
|
comicvine,
|
||||||
@@ -62,53 +41,82 @@ export const WantedComicsList = ({
|
|||||||
locg,
|
locg,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { issueName, url } = determineCoverFile(
|
const {
|
||||||
consolidatedComicMetadata,
|
issueName,
|
||||||
);
|
url,
|
||||||
|
publisher = null,
|
||||||
|
} = determineCoverFile(consolidatedComicMetadata);
|
||||||
const titleElement = (
|
const titleElement = (
|
||||||
<Link to={"/comic/details/" + _id}>
|
<Link to={"/comic/details/" + _id}>
|
||||||
{ellipsize(issueName, 20)}
|
{ellipsize(issueName, 20)}
|
||||||
|
<p>{publisher}</p>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={_id}
|
key={_id}
|
||||||
orientation={"vertical"}
|
orientation={"vertical-2"}
|
||||||
imageUrl={url}
|
imageUrl={url}
|
||||||
hasDetails
|
hasDetails
|
||||||
title={issueName ? titleElement : <span>No Name</span>}
|
title={issueName ? titleElement : <span>No Name</span>}
|
||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<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 */}
|
{/* comicVine metadata presence */}
|
||||||
{isComicBookMetadataAvailable && (
|
{isComicBookMetadataAvailable && (
|
||||||
<span className="icon custom-icon">
|
<img
|
||||||
<img src="/src/client/assets/img/cvlogo.svg" />
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
</span>
|
alt={"ComicVine metadata detected."}
|
||||||
|
className="w-7 h-7"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!isEmpty(locg) && (
|
{!isEmpty(locg) && (
|
||||||
<span className="icon custom-icon">
|
<img
|
||||||
<img src="/src/client/assets/img/locglogo.svg" />
|
src="/src/client/assets/img/locglogo.svg"
|
||||||
</span>
|
className="w-7 h-7"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Issue type */}
|
|
||||||
{isComicBookMetadataAvailable &&
|
|
||||||
!isNil(
|
|
||||||
detectIssueTypes(comicvine.volumeInformation.description),
|
|
||||||
) ? (
|
|
||||||
<span className="tag is-warning">
|
|
||||||
{
|
|
||||||
detectIssueTypes(
|
|
||||||
comicvine.volumeInformation.description,
|
|
||||||
).displayName
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</Masonry>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ interface ZeroStateProps {
|
|||||||
}
|
}
|
||||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<article className="message is-info">
|
<article className="">
|
||||||
<div className="message-body">
|
<div className="">
|
||||||
<p>{props.header}</p>
|
<p>{props.header}</p>
|
||||||
{props.message}
|
{props.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -226,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 mt-4 rounded-lg border border-gray-200">
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
<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>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import React, { useMemo, ReactElement, useState } from "react";
|
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router";
|
||||||
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 { useQuery, keepPreviousData } from "@tanstack/react-query";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -20,32 +25,56 @@ 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({
|
||||||
// Method to fetch paginated issues
|
query: {},
|
||||||
const fetchIssues = async (searchQuery, offset, type) => {
|
pagination: {
|
||||||
let pagination = {
|
size: 25,
|
||||||
size: 15,
|
|
||||||
from: offset,
|
from: offset,
|
||||||
};
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||||
|
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||||
|
*/
|
||||||
|
const fetchIssues = async (searchQuery) => {
|
||||||
|
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: {
|
||||||
searchQuery,
|
query,
|
||||||
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],
|
queryKey: ["comics", offset, searchQuery],
|
||||||
queryFn: () => fetchIssues({}, offset, "all"),
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
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) => {
|
||||||
@@ -56,7 +85,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 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 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="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>
|
||||||
@@ -123,7 +152,7 @@ export const Library = (): ReactElement => {
|
|||||||
accessorKey: "_source.createdAt",
|
accessorKey: "_source.createdAt",
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
return !isNil(info.getValue()) ? (
|
return !isNil(info.getValue()) ? (
|
||||||
<div className="text-xs w-max ml-3 my-3 text-slate-600">
|
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||||
{format(parseISO(info.getValue()), "h aaaa")}
|
{format(parseISO(info.getValue()), "h aaaa")}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +178,7 @@ export const Library = (): ReactElement => {
|
|||||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||||
Torrent: {info.getValue().torrent.downloads.length}
|
Torrent: {info.getValue().torrent.length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +200,17 @@ export const Library = (): ReactElement => {
|
|||||||
**/
|
**/
|
||||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||||
if (!isPlaceholderData) {
|
if (!isPlaceholderData) {
|
||||||
setOffset(pageSize * pageIndex + 1);
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// setOffset(pageSize * pageIndex + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,7 +228,17 @@ export const Library = (): ReactElement => {
|
|||||||
} else {
|
} else {
|
||||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||||
}
|
}
|
||||||
setOffset(from);
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// setOffset(from);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ImportStatus.propTypes = {
|
// ImportStatus.propTypes = {
|
||||||
@@ -215,7 +264,7 @@ export const Library = (): ReactElement => {
|
|||||||
</header>
|
</header>
|
||||||
{!isUndefined(searchResults?.hits) ? (
|
{!isUndefined(searchResults?.hits) ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="library">
|
<div>
|
||||||
<T2Table
|
<T2Table
|
||||||
totalPages={searchResults.hits.total.value}
|
totalPages={searchResults.hits.total.value}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -225,11 +274,13 @@ 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"
|
||||||
@@ -247,7 +298,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,
|
||||||
)}
|
)}
|
||||||
@@ -255,7 +306,7 @@ export const Library = (): ReactElement => {
|
|||||||
) : null}
|
) : null}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { isNil, isEmpty, isUndefined } from "lodash";
|
|||||||
import Masonry from "react-masonry-css";
|
import Masonry from "react-masonry-css";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
|
|
||||||
interface ILibraryGridProps {}
|
interface ILibraryGridProps {}
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
import React, { ReactElement, useCallback } from "react";
|
import React, { ReactElement } 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";
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
|
|
||||||
export const SearchBar = (): ReactElement => {
|
export const SearchBar = (props): ReactElement => {
|
||||||
const handleSubmit = useCallback((e) => {
|
const { searchHandler } = props;
|
||||||
// dispatch(
|
|
||||||
// searchIssue(
|
|
||||||
// {
|
|
||||||
// query: {
|
|
||||||
// volumeName: e.search,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// pagination: {
|
|
||||||
// size: 25,
|
|
||||||
// from: 0,
|
|
||||||
// },
|
|
||||||
// type: "volumeName",
|
|
||||||
// trigger: "libraryPage",
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={searchHandler}
|
||||||
initialValues={{}}
|
initialValues={{}}
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import React, { useCallback, ReactElement } from "react";
|
import React, { ReactElement, useState } from "react";
|
||||||
import { isNil, isEmpty } from "lodash";
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||||
import { importToDB } from "../../actions/fileops.actions";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
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 {}
|
||||||
|
|
||||||
@@ -17,164 +23,445 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
const formData = {
|
const formData = {
|
||||||
search: "",
|
search: "",
|
||||||
};
|
};
|
||||||
const dispatch = useDispatch();
|
const [comicVineMetadata, setComicVineMetadata] = useState({});
|
||||||
const getCVSearchResults = useCallback(
|
const [selectedResource, setSelectedResource] = useState("volume");
|
||||||
(searchQuery) => {
|
const { t } = useTranslation();
|
||||||
dispatch(
|
const handleResourceChange = (value) => {
|
||||||
comicinfoAPICall({
|
setSelectedResource(value);
|
||||||
callURIAction: "search",
|
};
|
||||||
callMethod: "GET",
|
|
||||||
callParams: {
|
const {
|
||||||
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
|
mutate,
|
||||||
query: searchQuery.search,
|
data: comicVineSearchResults,
|
||||||
format: "json",
|
isPending,
|
||||||
limit: "10",
|
isSuccess,
|
||||||
offset: "0",
|
} = useMutation({
|
||||||
field_list:
|
mutationFn: async (data: { search: string; resource: string }) => {
|
||||||
"id,name,deck,api_detail_url,image,description,volume,cover_date",
|
const { search, resource } = data;
|
||||||
resources: "issue",
|
return await axios({
|
||||||
},
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[dispatch],
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
// add to library
|
||||||
(sourceName: string, comicData) =>
|
const { data: additionResult, mutate: addToWantedList } = useMutation({
|
||||||
dispatch(importToDB(sourceName, { comicvine: comicData })),
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToLibrary = (sourceName: string, comicData) =>
|
||||||
|
setComicVineMetadata({ sourceName, comicData });
|
||||||
|
|
||||||
const comicVineSearchResults = useSelector(
|
|
||||||
(state: RootState) => state.comicInfo.searchResults,
|
|
||||||
);
|
|
||||||
const createDescriptionMarkup = (html) => {
|
const createDescriptionMarkup = (html) => {
|
||||||
return { __html: html };
|
return { __html: html };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const onSubmit = async (values) => {
|
||||||
<>
|
const formData = { ...values, resource: selectedResource };
|
||||||
<section className="container">
|
try {
|
||||||
<div className="section search">
|
mutate(formData);
|
||||||
<h1 className="title">Search</h1>
|
} catch (error) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
|
<div className="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">
|
||||||
|
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={getCVSearchResults}
|
onSubmit={onSubmit}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
...formData,
|
...formData,
|
||||||
}}
|
}}
|
||||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||||
<form onSubmit={handleSubmit} className="form columns search">
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="column is-three-quarters search">
|
<div className="flex flex-row w-full">
|
||||||
<Field name="search">
|
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
|
||||||
{({ input, meta }) => {
|
<div className="w-10 text-gray-400">
|
||||||
return (
|
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||||
<input
|
</div>
|
||||||
{...input}
|
|
||||||
className="input main-search-bar is-large"
|
<Field name="search">
|
||||||
placeholder="Type an issue/volume name"
|
{({ input, meta }) => {
|
||||||
/>
|
return (
|
||||||
);
|
<input
|
||||||
}}
|
{...input}
|
||||||
</Field>
|
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
|
||||||
</div>
|
placeholder="Type an issue/volume name"
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{!isNil(comicVineSearchResults.results) &&
|
</div>
|
||||||
!isEmpty(comicVineSearchResults.results) ? (
|
{isPending && (
|
||||||
<div className="columns is-multiline">
|
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{comicVineSearchResults.results.map((result) => {
|
Loading results...
|
||||||
return (
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
|
{comicVineSearchResults.data.results.map((result) => {
|
||||||
|
return result.resource_type === "issue" ? (
|
||||||
|
<div
|
||||||
|
key={result.id}
|
||||||
|
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||||
|
<Card
|
||||||
|
key={result.id}
|
||||||
|
orientation={"cover-only"}
|
||||||
|
imageUrl={result.image.small_url}
|
||||||
|
hasDetails={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-3/4">
|
||||||
|
<div className="text-xl">
|
||||||
|
{!isEmpty(result.volume.name) ? (
|
||||||
|
result.volume.name
|
||||||
|
) : (
|
||||||
|
<span className="is-size-3">No Name</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{result.cover_date && (
|
||||||
|
<p>
|
||||||
|
<span className="tag is-light">Cover date</span>
|
||||||
|
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="tag is-warning">{result.id}</p>
|
||||||
|
|
||||||
|
<a href={result.api_detail_url}>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
<p className="text-sm">
|
||||||
|
{ellipsize(
|
||||||
|
convert(result.description, {
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p", "div"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
320,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
result.resource_type === "volume" && (
|
||||||
<div
|
<div
|
||||||
key={result.id}
|
key={result.id}
|
||||||
className="comicvine-metadata column is-10 mb-3"
|
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="columns">
|
<div className="flex flex-row">
|
||||||
<div className="column is-one-quarter">
|
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||||
<Card
|
<Card
|
||||||
key={result.id}
|
key={result.id}
|
||||||
orientation={"vertical"}
|
orientation={"cover-only"}
|
||||||
imageUrl={result.image.small_url}
|
imageUrl={result.image.small_url}
|
||||||
title={result.name}
|
|
||||||
hasDetails={false}
|
hasDetails={false}
|
||||||
></Card>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="column">
|
<div className="w-3/4">
|
||||||
<div className="is-size-3">
|
<div className="text-xl">
|
||||||
{!isEmpty(result.name) ? (
|
{!isEmpty(result.name) ? (
|
||||||
result.name
|
result.name
|
||||||
) : (
|
) : (
|
||||||
<span className="is-size-3">No Name</span>
|
<span className="text-xl">No Name</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
{result.start_year && <> ({result.start_year})</>}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag is-warning">
|
|
||||||
{result.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href={result.api_detail_url}>
|
<div className="flex flex-row gap-2">
|
||||||
{result.api_detail_url}
|
{/* issue count */}
|
||||||
</a>
|
{result.count_of_issues && (
|
||||||
|
<div className="my-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{t("issueWithCount", {
|
||||||
|
count: result.count_of_issues,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* type: TPB, one-shot, graphic novel etc. */}
|
||||||
|
{!isNil(result.description) &&
|
||||||
|
!isUndefined(result.description) && (
|
||||||
|
<>
|
||||||
|
{!isEmpty(
|
||||||
|
detectIssueTypes(result.description),
|
||||||
|
) && (
|
||||||
|
<div className="my-2">
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
|
{
|
||||||
|
detectIssueTypes(result.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="tag is-warning">{result.id}</span>
|
||||||
<p>
|
<p>
|
||||||
|
<a href={result.api_detail_url}>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* description */}
|
||||||
|
<p className="text-sm">
|
||||||
{ellipsize(
|
{ellipsize(
|
||||||
convert(result.description, {
|
convert(result.description, {
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p"],
|
selectors: ["p", "div"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
320,
|
320,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div className="mt-2">
|
||||||
className="button is-success is-light is-outlined mt-2"
|
<PopoverButton
|
||||||
onClick={() => addToLibrary("comicvine", result)}
|
content={`Adding this volume will add ${t(
|
||||||
>
|
"issueWithCount",
|
||||||
<i className="fa-solid fa-plus mr-2"></i> Want
|
{
|
||||||
</button>
|
count: result.count_of_issues,
|
||||||
|
},
|
||||||
|
)} to your wanted list.`}
|
||||||
|
clickHandler={() =>
|
||||||
|
addToWantedList({
|
||||||
|
source: "comicvine",
|
||||||
|
comicObject: result,
|
||||||
|
markEntireVolumeWanted: true,
|
||||||
|
resourceType: "volume",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
) : (
|
</div>
|
||||||
<article className="message is-dark is-half">
|
) : (
|
||||||
<div className="message-body">
|
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<p className="mb-2">
|
<article
|
||||||
<span className="tag is-medium is-info is-light">
|
role="alert"
|
||||||
Search the ComicVine database
|
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"
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
import React, { ReactElement, useState } 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 () =>
|
||||||
@@ -29,23 +22,31 @@ 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 () => await airDCPPSocketInstance.get(`hubs`),
|
queryFn: async () =>
|
||||||
|
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.map(({ hub_url, identity }) => ({
|
hubList = hubs?.data.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`,
|
||||||
@@ -56,79 +57,112 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
|||||||
settingsKey: "directConnect",
|
settingsKey: "directConnect",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
queryClient.setQueryData(["settings"], (oldData: any) =>
|
||||||
|
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={mutate}
|
onSubmit={(values) => {
|
||||||
|
mutation.mutate(values);
|
||||||
|
}}
|
||||||
validate={validate}
|
validate={validate}
|
||||||
render={({ handleSubmit }) => (
|
render={({ handleSubmit }) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} className="mt-10">
|
||||||
<div>
|
<h2 className="text-xl">Configure DC++ Hubs</h2>
|
||||||
<h3 className="title">Hubs</h3>
|
<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"
|
||||||
|
>
|
||||||
<h6 className="subtitle has-text-grey-light">
|
<h6 className="subtitle has-text-grey-light">
|
||||||
Select the hubs you want to perform searches against.
|
Select the hubs you want to perform searches against. Your
|
||||||
|
selection in the dropdown <strong>will replace</strong> the
|
||||||
|
existing selection.
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</article>
|
||||||
<div className="field">
|
|
||||||
<label className="label">AirDC++ Host</label>
|
|
||||||
<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">
|
<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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<article
|
||||||
<article className="message">
|
role="alert"
|
||||||
<div className="message-body">
|
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"
|
||||||
No configured hubs detected in AirDC++. <br />
|
>
|
||||||
Configure to a hub in AirDC++ and then select a default hub here.
|
<div className="message-body">
|
||||||
</div>
|
No configured hubs detected in AirDC++. <br />
|
||||||
</article>
|
Configure to a hub in AirDC++ and then select a default hub here.
|
||||||
</>
|
</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 className="message-body is-size-6 is-family-secondary"></div>
|
||||||
Your selection in the dropdown <strong>will replace</strong> the
|
|
||||||
existing selection.
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div className="box mt-3">
|
<div>
|
||||||
<h6>Default Hub For Searches:</h6>
|
<span className="flex items-center mt-10 mb-4">
|
||||||
{settings?.data.directConnect?.client.hubs.map(
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
({ value, label }) => (
|
Default Hub for Searches
|
||||||
<div key={value}>
|
</span>
|
||||||
<div>{label}</div>
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
<span className="is-size-7">{value}</span>
|
</span>
|
||||||
</div>
|
<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">
|
||||||
),
|
{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}
|
||||||
|
|||||||
@@ -1,76 +1,65 @@
|
|||||||
import React, { ReactElement, useCallback } from "react";
|
import React, { useState, useEffect } 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 { initializeAirDCPPSocket, useStore } from "../../../store/index";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
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 = (): ReactElement => {
|
export const AirDCPPSettingsForm = () => {
|
||||||
// cherry-picking selectors for:
|
const [airDCPPSessionInformation, setAirDCPPSessionInformation] =
|
||||||
// 1. initial values for the form
|
useState(null);
|
||||||
// 2. If initial values are present, get the socket information to display
|
// Fetching all settings
|
||||||
const { setState } = useStore;
|
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({
|
||||||
const {
|
queryKey: ["airDCPPSettings"],
|
||||||
airDCPPSocketConnected,
|
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
||||||
airDCPPDisconnectionInfo,
|
});
|
||||||
airDCPPSessionInformation,
|
|
||||||
airDCPPClientConfiguration,
|
|
||||||
airDCPPSocketInstance,
|
|
||||||
setAirDCPPSocketInstance,
|
|
||||||
} = useStore(
|
|
||||||
useShallow((state) => ({
|
|
||||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
|
||||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
|
||||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
|
||||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
|
||||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
|
||||||
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
// Fetch session information
|
||||||
* Mutation to update settings and subsequently initialize
|
const fetchSessionInfo = (host) => {
|
||||||
* AirDC++ socket with those settings
|
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
||||||
*/
|
};
|
||||||
|
|
||||||
|
// Use effect to trigger side effects on settings fetch success
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) {
|
||||||
|
const host = settingsData.data.directConnect.client.host;
|
||||||
|
fetchSessionInfo(host).then((response) => {
|
||||||
|
setAirDCPPSessionInformation(response.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settingsSuccess, settingsData]);
|
||||||
|
|
||||||
|
// Handle setting update and subsequent AirDC++ initialization
|
||||||
const { mutate } = useMutation({
|
const { mutate } = useMutation({
|
||||||
mutationFn: async (values) =>
|
mutationFn: (values) => {
|
||||||
await axios({
|
console.log(values);
|
||||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
method: "POST",
|
settingsPayload: values,
|
||||||
data: { settingsPayload: values, settingsKey: "directConnect" },
|
settingsKey: "directConnect",
|
||||||
}),
|
|
||||||
onSuccess: async (values) => {
|
|
||||||
const {
|
|
||||||
data: {
|
|
||||||
directConnect: {
|
|
||||||
client: { host },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = values;
|
|
||||||
const dcppSocketInstance = await initializeAirDCPPSocket(host);
|
|
||||||
setState({
|
|
||||||
airDCPPClientConfiguration: host,
|
|
||||||
airDCPPSocketInstance: dcppSocketInstance,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
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(
|
|
||||||
async () =>
|
const deleteSettingsMutation = useMutation(() =>
|
||||||
await axios.post("http://localhost:3000/api/settings/saveSettings", {
|
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
settingsPayload: {},
|
settingsPayload: {},
|
||||||
settingsKey: "directConnect",
|
settingsKey: "directConnect",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// const removeSettings = useCallback(async () => {
|
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
||||||
// // airDCPPSettings.setSettings({});
|
|
||||||
// }, []);
|
|
||||||
//
|
|
||||||
const initFormData = !isUndefined(airDCPPClientConfiguration)
|
|
||||||
? airDCPPClientConfiguration
|
|
||||||
: {};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConnectionForm
|
<ConnectionForm
|
||||||
@@ -79,13 +68,12 @@ export const AirDCPPSettingsForm = (): ReactElement => {
|
|||||||
formHeading={"Configure AirDC++"}
|
formHeading={"Configure AirDC++"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEmpty(airDCPPSessionInformation) ? (
|
{airDCPPSessionInformation && (
|
||||||
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{!isEmpty(airDCPPClientConfiguration) ? (
|
{settingsData?.data && (
|
||||||
<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()}
|
||||||
@@ -93,7 +81,7 @@ export const AirDCPPSettingsForm = (): ReactElement => {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
@@ -14,19 +14,9 @@ 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"],
|
||||||
@@ -35,7 +25,6 @@ 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({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { 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";
|
||||||
@@ -11,6 +12,7 @@ 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");
|
||||||
|
console.log(active);
|
||||||
const settingsContent = [
|
const settingsContent = [
|
||||||
{
|
{
|
||||||
id: "adc-hubs",
|
id: "adc-hubs",
|
||||||
@@ -36,6 +38,14 @@ export const Settings = (props: ISettingsProps): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "prwlr-connection",
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<ProwlarrSettingsForm />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "core-service",
|
id: "core-service",
|
||||||
content: <>a</>,
|
content: <>a</>,
|
||||||
@@ -67,67 +77,69 @@ export const Settings = (props: ISettingsProps): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-cols max-w-screen-xl mx-auto">
|
<div className="flex flex-row">
|
||||||
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
|
||||||
{map(settingsObject, (settingObject, idx) => {
|
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
return (
|
{map(settingsObject, (settingObject, idx) => {
|
||||||
<div
|
return (
|
||||||
className="w-64 py-2 text-slate-700 dark:text-slate-400"
|
<div
|
||||||
key={idx}
|
className="w-64 py-2 text-slate-700 dark:text-slate-400"
|
||||||
>
|
key={idx}
|
||||||
<h3 className="text-l pb-2">
|
>
|
||||||
{settingObject.category.toUpperCase()}
|
<h3 className="text-l pb-2">
|
||||||
</h3>
|
{settingObject.category.toUpperCase()}
|
||||||
{/* First level children */}
|
</h3>
|
||||||
{!isUndefined(settingObject.children) ? (
|
{/* First level children */}
|
||||||
<ul key={settingObject.id}>
|
{!isUndefined(settingObject.children) ? (
|
||||||
{map(settingObject.children, (item, idx) => {
|
<ul key={settingObject.id}>
|
||||||
return (
|
{map(settingObject.children, (item, idx) => {
|
||||||
<li key={idx} className="mb-2">
|
return (
|
||||||
<a
|
<li key={idx} className="mb-2">
|
||||||
className={
|
<a
|
||||||
item.id.toString() === active
|
className={
|
||||||
? "is-active flex items-center"
|
item.id.toString() === active
|
||||||
: "flex items-center"
|
? "is-active flex items-center"
|
||||||
}
|
: "flex items-center"
|
||||||
onClick={() => setActive(item.id.toString())}
|
}
|
||||||
>
|
onClick={() => setActive(item.id.toString())}
|
||||||
{item.displayName}
|
>
|
||||||
</a>
|
{item.displayName}
|
||||||
{/* Second level children */}
|
</a>
|
||||||
{!isUndefined(item.children) ? (
|
{/* Second level children */}
|
||||||
<ul className="pl-4 mt-2">
|
{!isUndefined(item.children) ? (
|
||||||
{map(item.children, (item, idx) => (
|
<ul className="pl-4 mt-2">
|
||||||
<li key={item.id} className="mb-2">
|
{map(item.children, (item, idx) => (
|
||||||
<a
|
<li key={item.id} className="mb-2">
|
||||||
className={
|
<a
|
||||||
item.id.toString() === active
|
className={
|
||||||
? "is-active flex items-center"
|
item.id.toString() === active
|
||||||
: "flex items-center"
|
? "is-active flex items-center"
|
||||||
}
|
: "flex items-center"
|
||||||
onClick={() =>
|
}
|
||||||
setActive(item.id.toString())
|
onClick={() =>
|
||||||
}
|
setActive(item.id.toString())
|
||||||
>
|
}
|
||||||
{item.displayName}
|
>
|
||||||
</a>
|
{item.displayName}
|
||||||
</li>
|
</a>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
) : null}
|
</ul>
|
||||||
</li>
|
) : null}
|
||||||
);
|
</li>
|
||||||
})}
|
);
|
||||||
</ul>
|
})}
|
||||||
) : null}
|
</ul>
|
||||||
</div>
|
) : null}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</aside>
|
})}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* content for settings */}
|
{/* content for settings */}
|
||||||
<div className="max-w-screen-xl">
|
<div className="flex mx-12">
|
||||||
<div className="content">
|
<div className="">
|
||||||
{map(settingsContent, ({ id, content }) =>
|
{map(settingsContent, ({ id, content }) =>
|
||||||
active === id ? content : null,
|
active === id ? content : null,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,13 +48,11 @@ export const SystemSettingsForm = (): ReactElement => {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={
|
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"
|
||||||
isLoading ? "button is-danger is-loading" : "button is-danger"
|
|
||||||
}
|
|
||||||
onClick={() => flushDb()}
|
onClick={() => flushDb()}
|
||||||
>
|
>
|
||||||
<span className="icon">
|
<span className="pt-1 px-1">
|
||||||
<i className="fas fa-eraser"></i>
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Flush DB & Temporary Folders</span>
|
<span>Flush DB & Temporary Folders</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
|
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
|
||||||
import React, { useEffect, ReactElement, useState, useCallback } from "react";
|
import React, { ReactElement, useState, useCallback } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
||||||
getComicBookDetailById,
|
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||||
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
|
||||||
@@ -33,7 +28,9 @@ 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} />; */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -45,68 +42,145 @@ 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(issuesForVolume) ? (
|
{!isUndefined(issuesForSeries) ? (
|
||||||
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
|
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
|
||||||
Analyze Library
|
Analyze Library
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Masonry
|
<>
|
||||||
breakpointCols={breakpointColumnsObj}
|
{isSuccess &&
|
||||||
className="issues-container"
|
issuesForSeries.data.map((issue) => {
|
||||||
columnClassName="issues-column"
|
return (
|
||||||
>
|
<>
|
||||||
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
|
|
||||||
? issuesForVolume.map((issue) => {
|
|
||||||
return (
|
|
||||||
<Card
|
<Card
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
imageUrl={issue.image.thumb_url}
|
imageUrl={issue.image.small_url}
|
||||||
orientation={"vertical"}
|
orientation={"cover-only"}
|
||||||
hasDetails
|
hasDetails={false}
|
||||||
borderColorClass={
|
/>
|
||||||
!isEmpty(issue.matches) ? "green-border" : ""
|
<span className="tag is-warning mr-1">
|
||||||
}
|
{issue.issue_number}
|
||||||
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
|
</span>
|
||||||
onClick={() =>
|
{!isEmpty(issue.matches) ? (
|
||||||
openPotentialLibraryMatchesPanel(issue.matches)
|
<>
|
||||||
}
|
<span className="icon has-text-success">
|
||||||
>
|
<i className="fa-regular fa-asterisk"></i>
|
||||||
<span className="tag is-warning mr-1">
|
</span>
|
||||||
{issue.issue_number}
|
</>
|
||||||
</span>
|
) : null}
|
||||||
{!isEmpty(issue.matches) ? (
|
</>
|
||||||
<>
|
);
|
||||||
<span className="icon has-text-success">
|
})}
|
||||||
<i className="fa-regular fa-asterisk"></i>
|
</>
|
||||||
</span>
|
</>
|
||||||
</>
|
);
|
||||||
) : null}
|
|
||||||
</Card>
|
const Issues = () => (
|
||||||
);
|
<>
|
||||||
})
|
<article
|
||||||
: "loading"}
|
role="alert"
|
||||||
</Masonry>
|
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>
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,20 +189,44 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Issues in Volume",
|
name: "Issues in Volume",
|
||||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
|
||||||
content: <IssuesInVolume key={1} />,
|
content: <Issues />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
icon: <i className="fa-regular fa-mask"></i>,
|
icon: (
|
||||||
|
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
|
||||||
|
),
|
||||||
name: "Characters",
|
name: "Characters",
|
||||||
content: <div key={2}>asdasd</div>,
|
content: <div key={2}>Characters</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
icon: <i className="fa-solid fa-scroll"></i>,
|
icon: (
|
||||||
name: "Arcs",
|
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i>
|
||||||
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>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -136,21 +234,26 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
const MetadataTabGroup = () => {
|
const MetadataTabGroup = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="tabs">
|
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||||
<ul>
|
<div className="border-b border-gray-200">
|
||||||
{tabGroup.map(({ id, name, icon }) => (
|
<nav className="flex gap-4" aria-label="Tabs">
|
||||||
<li
|
{tabGroup.map(({ id, name, icon }) => (
|
||||||
key={id}
|
<a
|
||||||
className={id === active ? "is-active" : ""}
|
key={id}
|
||||||
onClick={() => setActive(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 ${
|
||||||
>
|
active === id
|
||||||
<a>
|
? "border-b border-cyan-50 dark:text-slate-200"
|
||||||
<span className="icon is-small">{icon}</span>
|
: "border-b border-transparent"
|
||||||
|
}`}
|
||||||
|
aria-current="page"
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
>
|
||||||
|
<span className="pt-1">{icon}</span>
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
))}
|
||||||
))}
|
</nav>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tabGroup.map(({ id, content }) => {
|
{tabGroup.map(({ id, content }) => {
|
||||||
return active === id ? content : null;
|
return active === id ? content : null;
|
||||||
@@ -158,97 +261,103 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
|
||||||
if (
|
const { sourcedMetadata } = comicObject.data;
|
||||||
!isUndefined(comicBookDetails.sourcedMetadata) &&
|
|
||||||
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container volume-details">
|
<>
|
||||||
<div className="section">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
{/* Title */}
|
<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">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
|
<div className="text-center sm:text-left">
|
||||||
</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
<div className="columns is-multiline">
|
Volumes
|
||||||
{/* Volume cover */}
|
</h1>
|
||||||
<div className="column is-narrow">
|
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse your collection of volumes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="container mx-auto mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
{/* Volume cover */}
|
||||||
<Card
|
<Card
|
||||||
imageUrl={
|
imageUrl={
|
||||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||||
.image.small_url
|
|
||||||
}
|
}
|
||||||
cardContainerStyle={{ maxWidth: 275 }}
|
orientation={"cover-only"}
|
||||||
orientation={"vertical"}
|
|
||||||
hasDetails={false}
|
hasDetails={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="column is-three-fifths">
|
|
||||||
<div className="field is-grouped mt-2">
|
|
||||||
{/* Comicvine Id */}
|
|
||||||
<div className="control">
|
|
||||||
<div className="tags has-addons">
|
|
||||||
<span className="tag">ComicVine Id</span>
|
|
||||||
<span className="tag is-info is-light">
|
|
||||||
{
|
|
||||||
comicBookDetails.sourcedMetadata.comicvine
|
|
||||||
.volumeInformation.id
|
|
||||||
}
|
|
||||||
</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">
|
|
||||||
{
|
|
||||||
comicBookDetails.sourcedMetadata.comicvine
|
|
||||||
.volumeInformation.publisher.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deck */}
|
|
||||||
<div>
|
<div>
|
||||||
{!isEmpty(
|
<div className="field is-grouped">
|
||||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
{/* Title */}
|
||||||
.description,
|
<span className="text-2xl">
|
||||||
)
|
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||||
? ellipsize(
|
</span>
|
||||||
convert(
|
{/* Comicvine Id */}
|
||||||
comicBookDetails.sourcedMetadata.comicvine
|
<div className="control">
|
||||||
.volumeInformation.description,
|
<div className="tags has-addons">
|
||||||
|
<span className="tag">ComicVine Id</span>
|
||||||
|
<span className="tag is-info is-light">
|
||||||
|
{sourcedMetadata.comicvine.volumeInformation.id}
|
||||||
|
</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">
|
||||||
{
|
{
|
||||||
baseElements: {
|
sourcedMetadata.comicvine.volumeInformation.publisher
|
||||||
selectors: ["p"],
|
.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deck */}
|
||||||
|
<div>
|
||||||
|
{!isEmpty(
|
||||||
|
sourcedMetadata.comicvine.volumeInformation.description,
|
||||||
|
)
|
||||||
|
? ellipsize(
|
||||||
|
convert(
|
||||||
|
sourcedMetadata.comicvine.volumeInformation
|
||||||
|
.description,
|
||||||
|
{
|
||||||
|
baseElements: {
|
||||||
|
selectors: ["p"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
),
|
||||||
),
|
300,
|
||||||
300,
|
)
|
||||||
)
|
: null}
|
||||||
: null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||||
</div>
|
</div>
|
||||||
|
<MetadataTabGroup />
|
||||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
|
||||||
</div>
|
</div>
|
||||||
<MetadataTabGroup />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SlidingPane
|
<SlidingPane
|
||||||
isOpen={visible}
|
isOpen={visible}
|
||||||
onRequestClose={() => setVisible(false)}
|
onRequestClose={() => setVisible(false)}
|
||||||
title={"Potential Matches in Library"}
|
title={"Potential Matches in Library"}
|
||||||
width={"600px"}
|
width={"600px"}
|
||||||
>
|
>
|
||||||
{slidingPanelContentId !== "" &&
|
{slidingPanelContentId !== "" &&
|
||||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
</SlidingPane>
|
</SlidingPane>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -4,124 +4,87 @@ 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 { isUndefined } from "lodash";
|
import { Link } from "react-router";
|
||||||
|
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);
|
||||||
useEffect(() => {
|
const {
|
||||||
// dispatch(
|
data: volumes,
|
||||||
// searchIssue(
|
isSuccess,
|
||||||
// {
|
isError,
|
||||||
// query: {},
|
isLoading,
|
||||||
// },
|
} = useQuery({
|
||||||
// {
|
queryFn: async () =>
|
||||||
// pagination: {
|
await axios({
|
||||||
// size: 25,
|
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||||
// from: 0,
|
method: "POST",
|
||||||
// },
|
data: {
|
||||||
// type: "volumes",
|
query: {},
|
||||||
// trigger: "volumesPage",
|
pagination: {
|
||||||
// },
|
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,
|
||||||
accessorKey: "_source",
|
accessorFn: (row) => row,
|
||||||
cell: (row) => {
|
cell: (row): any => {
|
||||||
const foo = row.getValue();
|
const comicObject = row.getValue();
|
||||||
|
const {
|
||||||
|
_source: { sourcedMetadata },
|
||||||
|
} = comicObject;
|
||||||
|
console.log("jaggu", row.getValue());
|
||||||
return (
|
return (
|
||||||
<div className="columns">
|
<div className="flex flex-row gap-3 mt-5">
|
||||||
<div className="column">
|
<Link to={`/volume/details/${comicObject._id}`}>
|
||||||
<div className="comic-detail issue-metadata">
|
<Card
|
||||||
<dl>
|
imageUrl={
|
||||||
<dd>
|
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||||
<div className="columns mt-2">
|
}
|
||||||
<div className="">
|
orientation={"cover-only"}
|
||||||
<Card
|
hasDetails={false}
|
||||||
imageUrl={
|
/>
|
||||||
foo.sourcedMetadata.comicvine.volumeInformation
|
</Link>
|
||||||
.image.thumb_url
|
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3">
|
||||||
}
|
<div className="text-xl mb-1 w-fit">
|
||||||
orientation={"vertical"}
|
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||||
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: "Download Status",
|
header: "Other Details",
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "Files",
|
header: "Downloads",
|
||||||
accessorKey: "_source.acquisition.directconnect",
|
accessorKey: "_source.acquisition.directconnect",
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: (props) => {
|
cell: (props) => {
|
||||||
@@ -142,12 +105,34 @@ export const Volumes = (props): ReactElement => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Type",
|
header: "Publisher",
|
||||||
id: "Air",
|
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
||||||
|
cell: (props): any => {
|
||||||
|
const row = props.getValue();
|
||||||
|
return <div className="mt-5 text-md">{row.publisher.name}</div>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Type",
|
header: "Issue Count",
|
||||||
id: "dcc",
|
accessorKey:
|
||||||
|
"_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>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -155,28 +140,45 @@ export const Volumes = (props): ReactElement => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="container">
|
<div>
|
||||||
<div className="section">
|
<section className="">
|
||||||
<div className="header-area">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<h1 className="title">Volumes</h1>
|
<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>
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
{!isUndefined(volumes.hits) && (
|
<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">
|
||||||
|
Browse your collection of volumes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{isSuccess ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<T2Table
|
<T2Table
|
||||||
sourceData={volumes?.hits}
|
sourceData={volumes?.data.hits.hits}
|
||||||
totalPages={volumes.hits.length}
|
totalPages={volumes?.data.hits.hits.length}
|
||||||
paginationHandlers={{
|
paginationHandlers={{
|
||||||
nextPage: () => {},
|
nextPage: () => {},
|
||||||
previousPage: () => {},
|
previousPage: () => {},
|
||||||
}}
|
}}
|
||||||
|
rowClickHandler={() => {}}
|
||||||
columns={columnData}
|
columns={columnData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
{isError ? (
|
||||||
</section>
|
<div>An error was encountered while retrieving volumes</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? <>Loading...</> : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
import SearchBar from "../Library/SearchBar";
|
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";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
|
||||||
export const WantedComics = (props): ReactElement => {
|
export const WantedComics = (props): ReactElement => {
|
||||||
// const wantedComics = useSelector(
|
const {
|
||||||
// (state: RootState) => state.fileOps.wantedComics,
|
data: wantedComics,
|
||||||
// );
|
isSuccess,
|
||||||
useEffect(() => {
|
isFetched,
|
||||||
// dispatch(
|
isError,
|
||||||
// searchIssue(
|
isLoading,
|
||||||
// {
|
} = useQuery({
|
||||||
// query: {},
|
queryFn: async () =>
|
||||||
// },
|
await axios({
|
||||||
// {
|
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||||
// pagination: {
|
method: "POST",
|
||||||
// size: 25,
|
data: {
|
||||||
// from: 0,
|
query: {},
|
||||||
// },
|
|
||||||
// type: "wanted",
|
|
||||||
// trigger: "wantedComicsPage"
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
type: "wanted",
|
||||||
|
trigger: "wantedComicsPage",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["wantedComics"],
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
const columnData = [
|
const columnData = [
|
||||||
{
|
{
|
||||||
header: "Comic Information",
|
header: "Comic Information",
|
||||||
@@ -36,7 +41,11 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
id: "comicDetails",
|
id: "comicDetails",
|
||||||
minWidth: 350,
|
minWidth: 350,
|
||||||
accessorFn: (data) => data,
|
accessorFn: (data) => data,
|
||||||
cell: (value) => <MetadataPanel data={value.getValue()} />,
|
cell: (value) => {
|
||||||
|
console.log("ASDASd", value);
|
||||||
|
const row = value.getValue()._source;
|
||||||
|
return row && <MetadataPanel data={row} />;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -45,8 +54,8 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "Files",
|
header: "Files",
|
||||||
accessorKey: "acquisition",
|
|
||||||
align: "right",
|
align: "right",
|
||||||
|
accessorKey: "_source.acquisition",
|
||||||
cell: (props) => {
|
cell: (props) => {
|
||||||
const {
|
const {
|
||||||
directconnect: { downloads },
|
directconnect: { downloads },
|
||||||
@@ -69,7 +78,7 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Download Details",
|
header: "Download Details",
|
||||||
id: "downloadDetails",
|
id: "downloadDetails",
|
||||||
accessorKey: "acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (data) => (
|
cell: (data) => (
|
||||||
<ol>
|
<ol>
|
||||||
{data.getValue().directconnect.downloads.map((download, idx) => {
|
{data.getValue().directconnect.downloads.map((download, idx) => {
|
||||||
@@ -98,23 +107,23 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
* @returns void
|
* @returns void
|
||||||
*
|
*
|
||||||
**/
|
**/
|
||||||
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
dispatch(
|
// dispatch(
|
||||||
searchIssue(
|
// searchIssue(
|
||||||
{
|
// {
|
||||||
query: {},
|
// query: {},
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
pagination: {
|
// pagination: {
|
||||||
size: pageSize,
|
// size: pageSize,
|
||||||
from: pageSize * pageIndex + 1,
|
// from: pageSize * pageIndex + 1,
|
||||||
},
|
// },
|
||||||
type: "wanted",
|
// type: "wanted",
|
||||||
trigger: "wantedComicsPage",
|
// trigger: "wantedComicsPage",
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagination control that fetches the previous x (pageSize) items
|
* Pagination control that fetches the previous x (pageSize) items
|
||||||
@@ -123,55 +132,71 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
* @param {number} pageSize
|
* @param {number} pageSize
|
||||||
* @returns void
|
* @returns void
|
||||||
**/
|
**/
|
||||||
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||||
let from = 0;
|
// let from = 0;
|
||||||
if (pageIndex === 2) {
|
// if (pageIndex === 2) {
|
||||||
from = (pageIndex - 1) * pageSize + 2 - 17;
|
// from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||||
} else {
|
// } else {
|
||||||
from = (pageIndex - 1) * pageSize + 2 - 16;
|
// from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||||
}
|
// }
|
||||||
dispatch(
|
// dispatch(
|
||||||
searchIssue(
|
// searchIssue(
|
||||||
{
|
// {
|
||||||
query: {},
|
// query: {},
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
pagination: {
|
// pagination: {
|
||||||
size: pageSize,
|
// size: pageSize,
|
||||||
from,
|
// from,
|
||||||
},
|
// },
|
||||||
type: "wanted",
|
// type: "wanted",
|
||||||
trigger: "wantedComicsPage",
|
// trigger: "wantedComicsPage",
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="container">
|
<div className="">
|
||||||
<div className="section">
|
<section className="">
|
||||||
<div className="header-area">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<h1 className="title">Wanted Comics</h1>
|
<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>
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
{!isEmpty(wantedComics) && (
|
<div className="text-center sm:text-left">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
|
Wanted Comics
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse through comics you marked as "wanted."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{isSuccess && wantedComics?.data.hits?.hits ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<T2Table
|
<T2Table
|
||||||
sourceData={wantedComics}
|
sourceData={wantedComics?.data.hits.hits}
|
||||||
totalPages={wantedComics.length}
|
totalPages={wantedComics?.data.hits.hits.length}
|
||||||
columns={columnData}
|
columns={columnData}
|
||||||
paginationHandlers={{
|
paginationHandlers={{
|
||||||
nextPage: nextPage,
|
nextPage: () => {},
|
||||||
previousPage: previousPage,
|
previousPage: () => {},
|
||||||
}}
|
}}
|
||||||
// rowClickHandler={navigateToComicDetail}
|
// rowClickHandler={navigateToComicDetail}
|
||||||
/>
|
/>
|
||||||
{/* pagination controls */}
|
{/* pagination controls */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
{isLoading ? <div>Loading...</div> : null}
|
||||||
</section>
|
{isError ? (
|
||||||
|
<div>An error occurred while retrieving the pull list.</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export const Canvas = (data) => {
|
export const Canvas = ({ data }) => {
|
||||||
const { colorHistogramData } = data.data;
|
const { colorHistogramData } = data;
|
||||||
console.log(data);
|
|
||||||
const width = 559;
|
const width = 559;
|
||||||
const height = 200;
|
const height = 200;
|
||||||
const pixelRatio = window.devicePixelRatio;
|
const pixelRatio = window.devicePixelRatio;
|
||||||
@@ -10,7 +9,11 @@ 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;
|
||||||
@@ -46,18 +49,24 @@ 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} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -83,24 +83,26 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
|
|
||||||
case "vertical-2":
|
case "vertical-2":
|
||||||
return (
|
return (
|
||||||
<div className="block rounded-md w-fit h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
<div className="block rounded-md max-w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
||||||
<img
|
<img
|
||||||
alt="Home"
|
alt="Home"
|
||||||
src={props.imageUrl}
|
src={props.imageUrl}
|
||||||
className="rounded-t-md object-cover"
|
className="rounded-t-md object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 px-2">
|
{props.title ? (
|
||||||
<dl>
|
<div className="px-3 pt-3 mb-2">
|
||||||
<div>
|
<dd className="text-sm text-slate-500 dark:text-black">
|
||||||
<dd className="text-md text-slate-500 dark:text-black">
|
{props.title}
|
||||||
{props.title}
|
</dd>
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</dl>
|
|
||||||
|
|
||||||
{props.hasDetails && <>{props.children}</>}
|
{props.hasDetails ? (
|
||||||
</div>
|
<div className="px-2">
|
||||||
|
<>{props.children}</>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
98
src/client/components/shared/DatePicker.tsx
Normal file
98
src/client/components/shared/DatePicker.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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;
|
||||||
@@ -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 pl-3 control-palette">
|
<div className="box p-2 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>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
type IHeaderProps = {
|
type IHeaderProps = {
|
||||||
headerContent: string;
|
headerContent: string;
|
||||||
subHeaderContent: string;
|
subHeaderContent: ReactElement;
|
||||||
iconClassNames: string;
|
iconClassNames: string;
|
||||||
|
link?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header = (props: IHeaderProps): ReactElement => {
|
export const Header = (props: IHeaderProps): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mt-7">
|
||||||
<h4 className="title is-4">
|
<div className="">
|
||||||
<i className={props.iconClassNames}></i> {props.headerContent}
|
{props.link ? (
|
||||||
</h4>
|
<Link to={props.link}>
|
||||||
<p className="subtitle is-7">{props.subHeaderContent}</p>
|
<span className="text-xl">
|
||||||
</>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
{prettyBytes(rawFileDetails.fileSize)}
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Uncompressed version available? */}
|
||||||
|
{rawFileDetails.archive?.uncompressed && (
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
),
|
),
|
||||||
@@ -105,7 +114,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dd className="is-size-7">
|
<dd className="is-size-7">
|
||||||
<span>
|
<span>
|
||||||
{ellipsize(
|
{ellipsize(
|
||||||
@@ -118,42 +126,13 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</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">
|
<span className="my-3 mx-2">
|
||||||
<div className="control">
|
{comicvine.volumeInformation.start_year}
|
||||||
<span className="tags">
|
</span>
|
||||||
<span
|
{comicvine.volumeInformation.count_of_issues}
|
||||||
className="tag is-success is-light has-text-weight-semibold"
|
ComicVine ID
|
||||||
style={props.tagsStyle}
|
{comicvine.id}
|
||||||
>
|
|
||||||
{comicvine.volumeInformation.start_year}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="tag is-success is-light"
|
|
||||||
style={props.tagsStyle}
|
|
||||||
>
|
|
||||||
{comicvine.volumeInformation.count_of_issues}
|
|
||||||
</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>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactElement, useState } from "react";
|
import React, { ReactElement, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||||
|
|
||||||
export const Navbar2 = (): ReactElement => {
|
export const Navbar2 = (): ReactElement => {
|
||||||
@@ -52,12 +52,12 @@ export const Navbar2 = (): ReactElement => {
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link
|
||||||
|
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
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
@@ -68,6 +68,14 @@ 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>
|
||||||
|
|
||||||
@@ -90,7 +98,7 @@ export const Navbar2 = (): ReactElement => {
|
|||||||
<li>
|
<li>
|
||||||
{/* Light/Dark Mode toggle */}
|
{/* Light/Dark Mode toggle */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-gray-600 dark:text-white">Light</span>
|
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||||
<label
|
<label
|
||||||
htmlFor="toggle"
|
htmlFor="toggle"
|
||||||
className="relative inline-flex items-center"
|
className="relative inline-flex items-center"
|
||||||
@@ -109,7 +117,7 @@ export const Navbar2 = (): ReactElement => {
|
|||||||
}`}
|
}`}
|
||||||
></span>
|
></span>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
<span className="text-gray-600 dark:text-white">Light</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
45
src/client/components/shared/PopoverButton.tsx
Normal file
45
src/client/components/shared/PopoverButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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 space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
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-sm bg-slate-400 p-2 rounded-md"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopoverButton;
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
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,
|
||||||
@@ -11,7 +8,19 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
export const T2Table = (tableOptions): ReactElement => {
|
interface T2TableProps {
|
||||||
|
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,
|
||||||
@@ -72,10 +81,9 @@ export const T2Table = (tableOptions): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<div className="container max-w-fit mx-14">
|
<div className="container max-w-fit mx-14">
|
||||||
<div>
|
<div>
|
||||||
{/* Search bar */}
|
<div className="flex flex-row gap-2 justify-between mt-7">
|
||||||
<div className="flex flex-row gap-2 justify-between mt-5">
|
{/* Search bar */}
|
||||||
<SearchBar />
|
{tableOptions.children}
|
||||||
|
|
||||||
{/* pagination controls */}
|
{/* pagination controls */}
|
||||||
<div>
|
<div>
|
||||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||||
@@ -145,14 +153,4 @@ export const T2Table = (tableOptions): ReactElement => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,6 @@ 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",
|
||||||
@@ -91,3 +90,24 @@ 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`,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const SUPPORTED_COMIC_ARCHIVES = [".cbz", ".cbr"];
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"displayName": "Prowlarr",
|
"displayName": "Prowlarr",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "prowlarr-connection",
|
"id": "prwlr-connection",
|
||||||
"displayName": "Connection"
|
"displayName": "Connection"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import React, { createContext } from "react";
|
|
||||||
|
|
||||||
export const SocketIOContext = createContext({});
|
|
||||||
|
|
||||||
export const SocketIOProvider = ({ children, socket }) => {
|
|
||||||
return (
|
|
||||||
<SocketIOContext.Provider value={socket}>
|
|
||||||
{children}
|
|
||||||
</SocketIOContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,45 +1,63 @@
|
|||||||
import React from "react";
|
|
||||||
import { render } from "react-dom";
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./components/App";
|
import App from "./components/App";
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||||
import Settings from "./components/Settings/Settings";
|
import Settings from "./components/Settings/Settings";
|
||||||
import { ErrorPage } from "./components/shared/ErrorPage";
|
import { ErrorPage } from "./components/shared/ErrorPage";
|
||||||
const rootEl = document.getElementById("root");
|
import i18n from "./shared/utils/i18n.util";
|
||||||
const root = createRoot(rootEl);
|
|
||||||
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();
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <App />,
|
||||||
|
errorElement: <ErrorPage />,
|
||||||
|
children: [
|
||||||
|
{ path: "/", element: <Dashboard /> },
|
||||||
|
{ path: "dashboard", element: <Dashboard /> },
|
||||||
|
{ path: "settings", element: <Settings /> },
|
||||||
|
{ path: "library", element: <TabulatedContentContainer category="library" /> },
|
||||||
|
{ path: "comic/details/:comicObjectId", element: <ComicDetailContainer /> },
|
||||||
|
{ path: "import", element: <Import path={"./comics"} /> },
|
||||||
|
{ path: "search", element: <Search /> },
|
||||||
|
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
|
||||||
|
{ path: "volumes", element: <Volumes /> },
|
||||||
|
{ path: "wanted", element: <WantedComics /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
{
|
{
|
||||||
path: "/",
|
future: {
|
||||||
element: <App />,
|
v7_relativeSplatPath: true,
|
||||||
errorElement: <ErrorPage />,
|
v7_fetcherPersist: true,
|
||||||
children: [
|
v7_normalizeFormMethod: true,
|
||||||
{ path: "dashboard", element: <Dashboard /> },
|
v7_partialHydration: true,
|
||||||
{ path: "settings", element: <Settings /> },
|
v7_skipActionErrorRevalidation: true,
|
||||||
{
|
},
|
||||||
path: "library",
|
}
|
||||||
element: <TabulatedContentContainer category="library" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "comic/details/:comicObjectId",
|
|
||||||
element: <ComicDetailContainer />,
|
|
||||||
},
|
|
||||||
{ path: "import", element: <Import path={"./comics"} /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
<ReactQueryDevtools initialIsOpen={true} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (rootElement) {
|
||||||
|
const root = createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider
|
||||||
|
router={router}
|
||||||
|
future={{ v7_startTransition: true }}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("Root element not found");
|
||||||
|
}
|
||||||
|
|||||||
4
src/client/locales/en/translation.json
Normal file
4
src/client/locales/en/translation.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"issueWithCount_one": "{{count}} issue",
|
||||||
|
"issueWithCount_other": "{{count}} issues"
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
25
src/client/shared/utils/i18n.util.ts
Normal file
25
src/client/shared/utils/i18n.util.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// 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;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { filter, isEmpty, isUndefined, min, minBy } from "lodash";
|
import { filter, isEmpty, isNil, 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) => {
|
export const determineCoverFile = (data): any => {
|
||||||
/* 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
|
||||||
@@ -19,36 +20,44 @@ export const determineCoverFile = (data) => {
|
|||||||
issueName: "",
|
issueName: "",
|
||||||
publisher: "",
|
publisher: "",
|
||||||
},
|
},
|
||||||
|
wanted: {
|
||||||
|
objectReference: "wanted",
|
||||||
|
priority: 2,
|
||||||
|
url: "",
|
||||||
|
issueName: "",
|
||||||
|
publisher: "",
|
||||||
|
},
|
||||||
comicvine: {
|
comicvine: {
|
||||||
objectReference: "comicvine",
|
objectReference: "comicvine",
|
||||||
priority: 2,
|
priority: 3,
|
||||||
url: "",
|
url: "",
|
||||||
issueName: "",
|
issueName: "",
|
||||||
publisher: "",
|
publisher: "",
|
||||||
},
|
},
|
||||||
locg: {
|
locg: {
|
||||||
objectReference: "locg",
|
objectReference: "locg",
|
||||||
priority: 3,
|
priority: 4,
|
||||||
url: "",
|
url: "",
|
||||||
issueName: "",
|
issueName: "",
|
||||||
publisher: "",
|
publisher: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (
|
// comicvine
|
||||||
!isUndefined(data.comicvine) &&
|
if (!isEmpty(data.comicvine)) {
|
||||||
!isUndefined(data.comicvine.volumeInformation)
|
coverFile.comicvine.url = data?.comicvine?.image.small_url;
|
||||||
) {
|
coverFile.comicvine.issueName = data.comicvine?.name;
|
||||||
coverFile.comicvine.url = data.comicvine.image.small_url;
|
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||||
coverFile.comicvine.issueName = data.comicvine.name;
|
|
||||||
coverFile.comicvine.publisher = data.comicvine.volumeInformation.publisher;
|
|
||||||
}
|
}
|
||||||
if (!isEmpty(data.rawFileDetails.cover)) {
|
// rawFileDetails
|
||||||
|
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;
|
||||||
@@ -69,25 +78,30 @@ export const determineCoverFile = (data) => {
|
|||||||
|
|
||||||
export const determineExternalMetadata = (
|
export const determineExternalMetadata = (
|
||||||
metadataSource: string,
|
metadataSource: string,
|
||||||
source: any
|
source: any,
|
||||||
) => {
|
): any => {
|
||||||
switch (metadataSource) {
|
if (!isNil(source)) {
|
||||||
case "comicvine":
|
switch (metadataSource) {
|
||||||
return {
|
case "comicvine":
|
||||||
coverURL: source.comicvine.image.small_url,
|
return {
|
||||||
issue: source.comicvine.name,
|
coverURL:
|
||||||
icon: "cvlogo.svg",
|
source.comicvine?.image.small_url ||
|
||||||
};
|
source.comicvine.volumeInformation?.image.small_url,
|
||||||
case "locg":
|
issue: source.comicvine.name,
|
||||||
return {
|
icon: "cvlogo.svg",
|
||||||
coverURL: source.locg.cover,
|
};
|
||||||
issue: source.locg.name,
|
case "locg":
|
||||||
icon: "locglogo.svg",
|
return {
|
||||||
};
|
coverURL: source.locg.cover,
|
||||||
case undefined:
|
issue: source.locg.name,
|
||||||
return {};
|
icon: "locglogo.svg",
|
||||||
|
};
|
||||||
|
case undefined:
|
||||||
|
return {};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isNil } from "lodash";
|
||||||
import io from "socket.io-client";
|
import io from "socket.io-client";
|
||||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import AirDCPPSocket from "../services/DcppSearchService";
|
|
||||||
import axios from "axios";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import "react-toastify/dist/ReactToastify.min.css";
|
||||||
|
|
||||||
/* Broadly, this file sets up:
|
/* Broadly, this file sets up:
|
||||||
* 1. The zustand-based global client state
|
* 1. The zustand-based global client state
|
||||||
* 2. socket.io client
|
* 2. socket.io client
|
||||||
* 3. AirDC++ websocket connection
|
|
||||||
*/
|
*/
|
||||||
export const useStore = create((set, get) => ({
|
export const useStore = create((set, get) => ({
|
||||||
// AirDC++ state
|
|
||||||
airDCPPSocketInstance: {},
|
|
||||||
airDCPPSocketConnected: false,
|
|
||||||
airDCPPDisconnectionInfo: {},
|
|
||||||
airDCPPClientConfiguration: {},
|
|
||||||
airDCPPSessionInformation: {},
|
|
||||||
setAirDCPPSocketConnectionStatus: () =>
|
|
||||||
set((value) => ({
|
|
||||||
airDCPPSocketConnected: value,
|
|
||||||
})),
|
|
||||||
airDCPPDownloadTick: {},
|
|
||||||
airDCPPTransfers: {},
|
|
||||||
// Socket.io state
|
// Socket.io state
|
||||||
socketIOInstance: {},
|
socketIOInstance: {},
|
||||||
|
// ComicVine Scraping status
|
||||||
|
comicvine: {
|
||||||
|
scrapingStatus: "",
|
||||||
|
},
|
||||||
|
|
||||||
// Import job queue and associated statuses
|
// Import job queue and associated statuses
|
||||||
importJobQueue: {
|
importJobQueue: {
|
||||||
@@ -93,6 +84,7 @@ if (!isNil(sessionId)) {
|
|||||||
"call",
|
"call",
|
||||||
"socket.resumeSession",
|
"socket.resumeSession",
|
||||||
{
|
{
|
||||||
|
namespace: "/",
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
(data) => console.log(data),
|
(data) => console.log(data),
|
||||||
@@ -121,11 +113,12 @@ socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
|||||||
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
|
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
|
||||||
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
|
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
|
||||||
const { completedJobCount, importResult } = data;
|
const { completedJobCount, importResult } = data;
|
||||||
|
console.log(importResult);
|
||||||
setState((state) => ({
|
setState((state) => ({
|
||||||
importJobQueue: {
|
importJobQueue: {
|
||||||
...state.importJobQueue,
|
...state.importJobQueue,
|
||||||
successfulJobCount: completedJobCount,
|
successfulJobCount: completedJobCount,
|
||||||
mostRecentImport: importResult.rawFileDetails.name,
|
mostRecentImport: importResult.data.rawFileDetails.name,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -139,6 +132,11 @@ socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socketIOInstance.on("searchResultsAvailable", (data) => {
|
||||||
|
console.log(data);
|
||||||
|
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
// 1b. Clear the localStorage sessionId upon receiving the
|
// 1b. Clear the localStorage sessionId upon receiving the
|
||||||
// LS_IMPORT_QUEUE_DRAINED event
|
// LS_IMPORT_QUEUE_DRAINED event
|
||||||
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
||||||
@@ -149,110 +147,15 @@ socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
|||||||
status: "drained",
|
status: "drained",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
console.log("a", queryClient);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// ComicVine Scraping status
|
||||||
* Method to init AirDC++ Socket with supplied settings
|
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
|
||||||
* @param configuration - credentials, and hostname details to init AirDC++ connection
|
setState((state) => ({
|
||||||
* @returns Initialized AirDC++ connection socket instance
|
comicvine: {
|
||||||
*/
|
...state.comicvine,
|
||||||
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
|
scrapingStatus: data.message,
|
||||||
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());
|
|
||||||
|
|||||||
Reference in New Issue
Block a user