Compare commits

...

29 Commits

Author SHA1 Message Date
463b67cb5b Merge branch 'main' into comicvine-integration-improvements 2024-05-11 15:03:10 -04:00
80746b0b0c 🏗️ Modified the Dockerfile 2024-05-11 15:01:26 -04:00
df835cad7e 🏗️ Automatic downloads WIP 2024-05-09 13:59:46 -04:00
dependabot[bot]
f57bd35cd4 Bump ejs from 3.1.9 to 3.1.10 (#111)
Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-09 13:50:12 -04:00
3f593b9efd 🔧 Refactored AirDC++ init in store 2024-04-23 22:45:29 -05:00
8dacff7850 🔧 Refactoring DC++ search/download 2024-04-17 21:14:07 -05:00
5f2500552c 🔧 Refactored Wanted component
Included # of issues in a wanted volume
2024-04-16 22:40:48 -05:00
1de01e7dbd 🔍 CV search metadata wrangling 2024-04-14 00:25:17 -04:00
6de8517bb5 🌍 Added i18n lib 2024-04-11 09:58:53 -04:00
dependabot[bot]
56c9fb03ac Bump tar from 6.2.0 to 6.2.1 (#110)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 22:48:46 -04:00
dependabot[bot]
26c9c1e562 Bump follow-redirects from 1.15.5 to 1.15.6 (#106)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 22:48:33 -04:00
dependabot[bot]
51e18c65d1 Bump express from 4.18.2 to 4.19.2 (#107)
Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 22:48:23 -04:00
dependabot[bot]
b82d5fd350 Bump vite from 5.0.12 to 5.0.13 (#108)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.12 to 5.0.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.0.13/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 22:48:03 -04:00
0024660f2e 🔍 Refining CV search UX 2024-04-10 22:35:25 -04:00
8fe49f7034 🔍 Improvements to CV search results 2024-04-09 17:38:14 -04:00
9315ad7454 🍇 Added some integration for issues 2024-04-08 13:30:31 -04:00
e54f997972 Added status checks 2024-04-04 06:39:07 -05:00
ca8c5dcf5b 📚 Wired up story arc fetching 2024-04-04 06:28:14 -05:00
5aecd66abb 🎨 Added some icons to tabs 2024-04-03 21:50:14 -05:00
64a4cfc8eb ️ Refactored VolumeDetail page to use react-query 2024-04-03 13:16:19 -05:00
9a3ccba719 🪢 Wiring up to addTorrent endpoint (#105)
* 🪢 Wiring up to addTorrent endpoint

* 🧲 Added a torrent download sub-panel

* 🧲 Fixed the auto-population of search box

* 🧲 Added downloads panel

* 🧲 Surfacing torrent progress in UI via scheduled job

* 🧲 Added visual indicators of torrent progress

* 💅🏼 Formatting improvements

* 💅🏼 Formatting tweaks to tabs
2024-03-30 21:41:05 -04:00
dependabot[bot]
c89e4af328 Bump es5-ext from 0.10.62 to 0.10.64 (#104)
Bumps [es5-ext](https://github.com/medikoo/es5-ext) from 0.10.62 to 0.10.64.
- [Release notes](https://github.com/medikoo/es5-ext/releases)
- [Changelog](https://github.com/medikoo/es5-ext/blob/main/CHANGELOG.md)
- [Commits](https://github.com/medikoo/es5-ext/compare/v0.10.62...v0.10.64)

---
updated-dependencies:
- dependency-name: es5-ext
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-27 22:31:32 -05:00
df4cecedf0 📝 Added captions to screenshots 2024-02-23 16:59:31 -05:00
3dee53f33f 📝 Updated README 2024-02-23 16:52:45 -05:00
ef05dee600 Removed pngs 2024-02-23 16:42:37 -05:00
2b6bce4731 Merge branch 'main' of https://github.com/rishighan/threetwo 2024-02-23 16:41:21 -05:00
fc4c5c61e2 🌇 Updated screenshots 2024-02-23 16:41:14 -05:00
ae04260f69 Updated .gitignore to ignore storybook static assets (#102)
*  Updated .gitignore to ignore storybook static assets

* 🏗️ Fixed cover-only config for CV detail panel

* 🏗️ Added stats to dashboard

* 🐯 Prowlarr integration WIP

* 🐯 Prowlarr settings form scaffold

* 🧲 Added a form for torrent search

* 🐯 Mocked a prowlarr call
2024-02-23 16:29:13 -05:00
dependabot[bot]
ad5fc0b8b3 Bump ip from 2.0.0 to 2.0.1 (#103)
Bumps [ip](https://github.com/indutny/node-ip) from 2.0.0 to 2.0.1.
- [Commits](https://github.com/indutny/node-ip/compare/v2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: ip
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 09:07:19 -06:00
61 changed files with 2014 additions and 2295 deletions

View File

@@ -3,17 +3,20 @@ 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 # Install build dependencies necessary for native modules
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 apk --no-cache add g++ make libpng-dev git python3 autoconf automake libjpeg-turbo-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
# Install node modules
RUN yarn install --ignore-engines
# 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" ]

View File

@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots ### Screenshots
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png) #### Dashboard
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png) ![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png) #### Issue View
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png) ![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel ### 🦄 Early Development Support Channel
@@ -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)

View File

@@ -18,6 +18,8 @@
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.1",
"@floating-ui/react": "^0.26.12",
"@floating-ui/react-dom": "^2.0.8",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
@@ -27,20 +29,23 @@
"@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.6.8",
"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.19.2",
"filename-parser": "^1.0.2", "filename-parser": "^1.0.2",
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"i18next": "^23.11.1",
"i18next-browser-languagedetector": "^7.2.1",
"i18next-http-backend": "^2.5.0",
"immer": "^10.0.3", "immer": "^10.0.3",
"jsdoc": "^3.6.10", "jsdoc": "^3.6.10",
"keen-slider": "^6.8.6", "keen-slider": "^6.8.6",
@@ -56,9 +61,9 @@
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4", "react-final-form-arrays": "^3.1.4",
"react-i18next": "^14.1.0",
"react-loader-spinner": "^4.0.0", "react-loader-spinner": "^4.0.0",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-popper": "^2.3.0",
"react-router": "^6.9.0", "react-router": "^6.9.0",
"react-router-dom": "^6.9.0", "react-router-dom": "^6.9.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
@@ -69,7 +74,9 @@
"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.2.7",
"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"
@@ -108,7 +115,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.19.2",
"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,7 +124,7 @@
"prettier": "^2.2.1", "prettier": "^2.2.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"rimraf": "^4.1.3", "rimraf": "^4.1.3",
"sass": "^1.69.5", "sass": "^1.77.0",
"storybook": "^7.3.2", "storybook": "^7.3.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",

BIN
screenshots/CVMatching.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
screenshots/ComicDetail.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
screenshots/Dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
screenshots/Import.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

BIN
screenshots/Library.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

View File

@@ -10,6 +10,7 @@ import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
@@ -21,17 +22,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,20 +33,54 @@ export const AcquisitionPanel = (
hub_urls: string[] | undefined | null; hub_urls: string[] | undefined | null;
priority: PriorityEnum; priority: PriorityEnum;
} }
interface SearchResult {
result: {
id: number;
};
search_id: number;
// Add other properties as needed
}
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({});
@@ -61,7 +88,7 @@ export const AcquisitionPanel = (
// 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 = {
@@ -69,7 +96,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);
@@ -80,80 +107,55 @@ 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(
await ADCPPSocket(); "call",
} "socket.search",
const instance: SearchInstance = await ADCPPSocket.post("search"); {
setAirDCPPSearchStatus(true); query: searchData,
config: {
// We want to get notified about every new result in order to make the user experience better protocol: `ws`,
await ADCPPSocket.addListener( hostname: `localhost:5600`,
`search`, username: `user`,
"search_result_added", password: `pass`,
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, },
); (data: any) => console.log(data),
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.result.id === groupedResult.result.id,
);
const updatedState = [...airDCPPSearchResults];
if (
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) {
updatedState[bundleToUpdateIndex] = groupedResult;
}
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
setAirDCPPSearchInstance(instance);
setAirDCPPSearchStatus(false);
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
}; };
socketIOInstance.on("searchResultAdded", (data: SearchResult) => {
setAirDCPPSearchResults((previousState) => {
const exists = previousState.some(
(item) => data.result.id === item.result.id,
);
if (!exists) {
return [...previousState, data];
}
return previousState;
});
});
socketIOInstance.on("searchResultUpdated", (groupedResult: SearchResult) => {
// ...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]);
});
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 +164,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,50 +174,22 @@ export const AcquisitionPanel = (
name: String, name: String,
size: Number, size: Number,
type: any, type: any,
ADCPPSocket: any, config: any,
): void => { ): 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,
},
});
console.log(bundleDBImportResult?.data);
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
// dispatch({
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
// downloadResult,
// bundleDBImportResult,
// });
//
// dispatch({
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
// comicBookDetail: bundleDBImportResult.data,
// IMS_inProgress: false,
// });
}
} catch (error) {
throw error;
}
}; };
const getDCPPSearchResults = async (searchQuery) => { const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = { const manualQuery = {
@@ -223,17 +197,17 @@ 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: map(hubs?.data, (hub) => hub.hub_url),
priority: 5, priority: 5,
}; };
search(manualQuery, airDCPPSocketInstance); search(manualQuery);
}; };
return ( return (
<> <>
<div className="mt-5"> <div className="mt-5">
{!isEmpty(airDCPPSocketInstance) ? ( {!isEmpty(hubs?.data) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
@@ -278,9 +252,9 @@ export const AcquisitionPanel = (
)} )}
/> />
) : ( ) : (
<div className="column is-three-fifths"> <div className="">
<article className="message is-info"> <article className="">
<div className="message-body is-size-6 is-family-secondary"> <div className="">
AirDC++ is not configured. Please configure it in{" "} AirDC++ is not configured. Please configure it in{" "}
<code>Settings &gt; AirDC++ &gt; Connection</code>. <code>Settings &gt; AirDC++ &gt; Connection</code>.
</div> </div>
@@ -298,7 +272,7 @@ export const AcquisitionPanel = (
<dl> <dl>
<dt> <dt>
<div className="mb-1"> <div className="mb-1">
{hubs.map((value, idx) => ( {hubs?.data.map((value, idx) => (
<span className="tag is-warning" key={idx}> <span className="tag is-warning" key={idx}>
{value.identity.name} {value.identity.name}
</span> </span>
@@ -358,7 +332,7 @@ export const AcquisitionPanel = (
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-500"> <tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{map(airDCPPSearchResults, ({ result }, idx) => { {map(airDCPPSearchResults, ({ result, search_id }, idx) => {
return ( return (
<tr <tr
key={idx} key={idx}
@@ -452,7 +426,12 @@ export const AcquisitionPanel = (
result.name, result.name,
result.size, result.size,
result.type, result.type,
airDCPPSocketInstance, {
protocol: `ws`,
hostname: `localhost:5600`,
username: `user`,
password: `pass`,
},
) )
} }
> >

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

View File

@@ -12,6 +12,7 @@ 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";
@@ -350,13 +351,18 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
</span> </span>
), ),
name: "Torrent Search", name: "Torrent Search",
content: <>Torrents</>, content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
shouldShow: true, shouldShow: true,
}, },
{ {
id: 6, id: 6,
name: "Downloads", name: "Downloads",
icon: <>{acquisition?.directconnect?.downloads?.length}</>, icon: (
<>
{acquisition?.directconnect?.downloads?.length +
acquisition?.torrent.length}
</>
),
content: content:
!isNil(data.data) && !isEmpty(data.data) ? ( !isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} /> <DownloadsPanel key={5} />

View File

@@ -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-dom"; import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail"; import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";

View File

@@ -16,7 +16,7 @@ export const ComicVineDetails = (props): ReactElement => {
<div className="min-w-fit"> <div className="min-w-fit">
<Card <Card
imageUrl={data.volumeInformation.image.thumb_url} imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical-2"} orientation={"cover-only"}
hasDetails={false} hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }} // cardContainerStyle={{ maxWidth: 200 }}
/> />

View File

@@ -1,12 +1,15 @@
import React, { useEffect, useContext, ReactElement, useState } from "react"; import React, { useEffect, useContext, ReactElement, useState } from "react";
import { RootState } from "threetwo-ui-typings"; import { RootState } from "threetwo-ui-typings";
import { isEmpty, map } from "lodash"; import { isEmpty, 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 { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import {
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@@ -20,12 +23,27 @@ export const DownloadsPanel = (
): ReactElement | null => { ): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [bundles, setBundles] = useState([]); const [bundles, setBundles] = useState([]);
const { airDCPPSocketInstance } = useStore( const [infoHashes, setInfoHashes] = useState<string[]>([]);
useShallow((state) => ({ const [torrentDetails, setTorrentDetails] = useState([]);
const [activeTab, setActiveTab] = useState("torrents");
const { airDCPPSocketInstance, socketIOInstance } = useStore(
useShallow((state: any) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance, airDCPPSocketInstance: state.airDCPPSocketInstance,
socketIOInstance: state.socketIOInstance,
})), })),
); );
// React to torrent progress data sent over websockets
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
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++ // Fetch the downloaded files and currently-downloading file(s) from AirDC++
const { data: comicObject, isSuccess } = useQuery({ const { data: comicObject, isSuccess } = useQuery({
queryKey: ["bundles"], queryKey: ["bundles"],
@@ -41,7 +59,6 @@ export const DownloadsPanel = (
}, },
}), }),
}); });
const getBundles = async (comicObject) => { const getBundles = async (comicObject) => {
if (comicObject?.data.acquisition.directconnect) { if (comicObject?.data.acquisition.directconnect) {
const filteredBundles = const filteredBundles =
@@ -54,65 +71,74 @@ export const DownloadsPanel = (
} }
}; };
// Call the scheduled job for fetching torrent data
// triggered by the active tab been set to "torrents"
const { data: torrentData } = useQuery({
queryFn: () =>
axios({
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
method: "GET",
params: {
trigger: activeTab,
},
}),
queryKey: [activeTab],
});
useEffect(() => { useEffect(() => {
getBundles(comicObject).then((result) => { getBundles(comicObject).then((result) => {
setBundles(result); setBundles(result);
}); });
}, [comicObject]); }, [comicObject]);
const Bundles = (props) => {
return (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead>
<tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Filename
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Size
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Time
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{map(props.data, (bundle) => (
<tr key={bundle.id} className="text-sm">
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="text-xs">
{ellipsize(bundle.target, 88)}
</span>
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{prettyBytes(bundle.size)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
return ( return (
<div className="columns is-multiline"> <div className="columns is-multiline">
{!isEmpty(airDCPPSocketInstance) && !isEmpty(bundles) && ( <div>
<Bundles data={bundles} /> <div className="sm:hidden">
)} <label htmlFor="Download Type" className="sr-only">
Download Type
</label>
<select id="Tab" className="w-full rounded-md border-gray-200">
<option>DC++ Downloads</option>
<option>Torrents</option>
</select>
</div>
<div className="hidden sm:block">
<nav className="flex gap-6" aria-label="Tabs">
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "directconnect"
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
aria-current="page"
onClick={() => setActiveTab("directconnect")}
>
DC++ Downloads
</a>
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "torrents"
? "bg-slate-200 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
onClick={() => setActiveTab("torrents")}
>
Torrents
</a>
</nav>
</div>
</div>
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
{!isEmpty(airDCPPSocketInstance) &&
!isEmpty(bundles) &&
activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
</div> </div>
); );
}; };

View File

@@ -48,7 +48,7 @@ export const RawFileDetails = (props): ReactElement => {
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900"> <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>

View File

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

View File

@@ -30,6 +30,8 @@ export const ArchiveOperations = (props): ReactElement => {
const [currentImage, setCurrentImage] = useState([]); const [currentImage, setCurrentImage] = useState([]);
const [uncompressedArchive, setUncompressedArchive] = useState([]); const [uncompressedArchive, setUncompressedArchive] = useState([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState({}); const [imageAnalysisResult, setImageAnalysisResult] = useState({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false);
const constructImagePaths = (data): Array<string> => { const constructImagePaths = (data): Array<string> => {
return data?.map((path: string) => return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)), escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
@@ -63,6 +65,7 @@ export const ArchiveOperations = (props): ReactElement => {
if (isMounted) { if (isMounted) {
setUncompressedArchive(uncompressedArchive); setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
} }
}, },
}); });
@@ -122,8 +125,9 @@ export const ArchiveOperations = (props): ReactElement => {
enabled: false, enabled: false,
}); });
if (isSuccess) { if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] }); queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
} }
// sliding panel init // sliding panel init
@@ -171,7 +175,8 @@ export const ArchiveOperations = (props): ReactElement => {
</div> </div>
</article> </article>
<div className="mt-5"> <div className="mt-5">
{data.rawFileDetails.archive?.uncompressed ? ( {data.rawFileDetails.archive?.uncompressed &&
!isEmpty(uncompressedArchive) ? (
<article <article
role="alert" role="alert"
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600" className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
@@ -187,7 +192,7 @@ export const ArchiveOperations = (props): ReactElement => {
) : null} ) : null}
<div className="flex flex-row gap-2 mt-4"> <div className="flex flex-row gap-2 mt-4">
{!data.rawFileDetails?.archive?.uncompressed ? ( {isEmpty(uncompressedArchive) ? (
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()} onClick={() => refetch()}

View File

@@ -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;

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

View File

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

View File

@@ -1,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>

View File

@@ -82,7 +82,17 @@ export const PullList = (): ReactElement => {
<div className="content"> <div className="content">
<Header <Header
headerContent="Discover" headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks" subHeaderContent={
<span className="text-md">
Pull List aggregated for the week from{" "}
<span className="underline">
<a href="https://leagueofcomicgeeks.com/comics/new-comics">
League Of Comic Geeks
</a>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
}
iconClassNames="fa-solid fa-binoculars mr-2" iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/" link="/pull-list/all/"
/> />
@@ -101,7 +111,10 @@ export const PullList = (): ReactElement => {
/> />
{inputValue && ( {inputValue && (
<div className="text-sm"> <div className="text-sm">
Showing pull list for <span>{inputValue}</span> Showing pull list for{" "}
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{inputValue}
</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -18,6 +18,7 @@ type RecentlyImportedProps = {
export const RecentlyImported = ( export const RecentlyImported = (
comics: RecentlyImportedProps, comics: RecentlyImportedProps,
): ReactElement => { ): ReactElement => {
console.log(comics);
return ( return (
<div> <div>
<Header <Header
@@ -33,9 +34,7 @@ export const RecentlyImported = (
rawFileDetails, rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg }, sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata, inferredMetadata,
acquisition: { wanted: { source } = {},
source: { name },
},
}, },
idx, idx,
) => { ) => {
@@ -45,11 +44,14 @@ export const RecentlyImported = (
comicInfo, comicInfo,
locg, locg,
}); });
const { issue, coverURL, icon } = determineExternalMetadata(name, { const { issue, coverURL, icon } = determineExternalMetadata(
comicvine, source,
comicInfo, {
locg, comicvine,
}); comicInfo,
locg,
},
);
const isComicVineMetadataAvailable = const isComicVineMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);

View File

@@ -31,10 +31,9 @@ export const WantedComicsList = ({
_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,
@@ -42,12 +41,15 @@ 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 (
@@ -59,28 +61,42 @@ export const WantedComicsList = ({
title={issueName ? titleElement : <span>No Name</span>} title={issueName ? titleElement : <span>No Name</span>}
> >
<div className="pb-1"> <div className="pb-1">
{/* Issue type */} <div className="flex flex-row gap-2">
{isComicBookMetadataAvailable && {/* Issue type */}
!isNil( {isComicBookMetadataAvailable &&
detectIssueTypes(comicvine.volumeInformation.description), !isNil(detectIssueTypes(comicvine.description)) ? (
) ? ( <div className="my-2">
<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="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">
<span className="pr-1 pt-1"> <i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i> </span>
</span>
<span className="text-md text-slate-500 dark:text-slate-900"> <span className="text-md text-slate-500 dark:text-slate-900">
{ {
detectIssueTypes( detectIssueTypes(comicvine.description)
comicvine.volumeInformation.description, .displayName
).displayName }
} </span>
</span> </span>
</span> </div>
</div> ) : null}
) : 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 && (
<img <img

View File

@@ -178,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>

View File

@@ -1,15 +1,16 @@
import React, { useCallback, ReactElement, useState } 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 { 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 { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
COMICVINE_SERVICE_URI, COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI, LIBRARY_SERVICE_BASE_URI,
@@ -22,65 +23,121 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [comicVineMetadata, setComicVineMetadata] = useState({}); const [comicVineMetadata, setComicVineMetadata] = useState({});
const getCVSearchResults = (searchQuery) => { const [selectedResource, setSelectedResource] = useState("volume");
setSearchQuery(searchQuery.search); const { t } = useTranslation();
// queryClient.invalidateQueries({ queryKey: ["comicvineSearchResults"] }); const handleResourceChange = (value) => {
setSelectedResource(value);
}; };
const { const {
mutate,
data: comicVineSearchResults, data: comicVineSearchResults,
isLoading, isPending,
isSuccess, isSuccess,
} = useQuery({ } = useMutation({
queryFn: async () => mutationFn: async (data: { search: string; resource: string }) => {
await axios({ const { search, resource } = data;
return await axios({
url: `${COMICVINE_SERVICE_URI}/search`, url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET", method: "GET",
params: { params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69", api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery, query: search,
format: "json", format: "json",
limit: "10", limit: "10",
offset: "0", offset: "0",
field_list: field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date", "id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: "issue", resources: resource,
}, },
}), });
queryKey: ["comicvineSearchResults", searchQuery], },
enabled: !isNil(searchQuery),
}); });
// add to library // add to library
const { data: additionResult } = useQuery({ const { data: additionResult, mutate: addToWantedList } = useMutation({
queryFn: async () => mutationFn: async ({
await axios({ source,
comicObject,
markEntireVolumeWanted,
resourceType,
}) => {
console.log("jigni", comicObject);
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,
api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
// Get volume metadata from CV
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
method: "POST",
data: {
volumeURI: comicObject.volume.api_detail_url,
fieldList:
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
},
});
// set volume metadata key
volumeInformation = response.data?.results;
break;
case "volume":
const {
id: volumeId,
api_detail_url: apiUrl,
image: volumeImage,
name,
publisher,
} = comicObject;
volumeInformation = {
id: volumeId,
url: apiUrl,
image: volumeImage,
name,
publisher,
};
break;
default:
console.log("Invalid resource type.");
break;
}
// Add to wanted list
return await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`, url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST", method: "POST",
data: { data: {
importType: "new", importType: "new",
payload: { payload: {
rawFileDetails: {
name: "",
},
importStatus: { importStatus: {
isImported: true, isImported: false, // wanted, but not acquired yet.
tagged: false, tagged: false,
matchedResult: { matchedResult: {
score: "0", score: "0",
}, },
}, },
sourcedMetadata: wanted: {
{ comicvine: comicVineMetadata?.comicData } || null, source,
acquisition: { source: { wanted: true, name: "comicvine" } }, markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
}, },
}, },
}), });
queryKey: ["additionResult"], },
enabled: !isNil(comicVineMetadata.comicData),
}); });
const addToLibrary = (sourceName: string, comicData) => const addToLibrary = (sourceName: string, comicData) =>
@@ -90,6 +147,15 @@ export const Search = ({}: ISearchProps): ReactElement => {
return { __html: html }; return { __html: html };
}; };
const onSubmit = async (values) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
return ( return (
<div> <div>
<section> <section>
@@ -110,7 +176,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
</header> </header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form <Form
onSubmit={getCVSearchResults} onSubmit={onSubmit}
initialValues={{ initialValues={{
...formData, ...formData,
}} }}
@@ -142,18 +208,73 @@ export const Search = ({}: ISearchProps): ReactElement => {
Search Search
</button> </button>
</div> </div>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form> </form>
)} )}
/> />
</div> </div>
{!isNil(comicVineSearchResults?.data.results) && {isPending && (
!isEmpty(comicVineSearchResults?.data.results) ? ( <div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Loading results...
</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"> <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) => { {comicVineSearchResults.data.results.map((result) => {
return isSuccess ? ( return result.resource_type === "issue" ? (
<div key={result.id} className="mb-5"> <div
key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
>
<div className="flex flex-row"> <div className="flex flex-row">
<div className="mr-5"> <div className="mr-5 min-w-[80px] max-w-[13%]">
<Card <Card
key={result.id} key={result.id}
orientation={"cover-only"} orientation={"cover-only"}
@@ -161,7 +282,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
hasDetails={false} hasDetails={false}
/> />
</div> </div>
<div className="column"> <div className="w-3/4">
<div className="text-xl"> <div className="text-xl">
{!isEmpty(result.volume.name) ? ( {!isEmpty(result.volume.name) ? (
result.volume.name result.volume.name
@@ -169,27 +290,19 @@ export const Search = ({}: ISearchProps): ReactElement => {
<span className="is-size-3">No Name</span> <span className="is-size-3">No Name</span>
)} )}
</div> </div>
<div className="field is-grouped mt-1"> {result.cover_date && (
<div className="control"> <p>
<div className="tags has-addons"> <span className="tag is-light">Cover date</span>
<span className="tag is-light">Cover date</span> {dayjs(result.cover_date).format("MMM D, YYYY")}
<span className="tag is-info is-light"> </p>
{dayjs(result.cover_date).format("MMM D, YYYY")} )}
</span>
</div>
</div>
<div className="control"> <p className="tag is-warning">{result.id}</p>
<div className="tags has-addons">
<span className="tag is-warning">{result.id}</span>
</div>
</div>
</div>
<a href={result.api_detail_url}> <a href={result.api_detail_url}>
{result.api_detail_url} {result.api_detail_url}
</a> </a>
<p> <p className="text-sm">
{ellipsize( {ellipsize(
convert(result.description, { convert(result.description, {
baseElements: { baseElements: {
@@ -200,19 +313,129 @@ export const Search = ({}: ISearchProps): ReactElement => {
)} )}
</p> </p>
<div className="mt-2"> <div className="mt-2">
<button <PopoverButton
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" content={`This will add ${result.volume.name} to your wanted list.`}
onClick={() => addToLibrary("comicvine", result)} clickHandler={() =>
> addToWantedList({
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "} source: "comicvine",
Mark as Wanted comicObject: result,
</button> markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div>Loading</div> result.resource_type === "volume" && (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 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.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>}
</div>
<div className="flex flex-row gap-2">
{/* issue count */}
{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>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`Adding this volume will add ${t(
"issueWithCount",
{
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>

View File

@@ -3,20 +3,11 @@ 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 { 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,
@@ -36,11 +27,19 @@ export const AirDCPPHubsForm = (): ReactElement => {
*/ */
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 = {};
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,
})); }));
@@ -101,7 +100,10 @@ export const AirDCPPHubsForm = (): ReactElement => {
/> />
) : ( ) : (
<> <>
<article className="message"> <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"
>
<div className="message-body"> <div className="message-body">
No configured hubs detected in AirDC++. <br /> No configured hubs detected in AirDC++. <br />
Configure to a hub in AirDC++ and then select a default hub here. Configure to a hub in AirDC++ and then select a default hub here.

View File

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

View File

@@ -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;

View File

@@ -16,16 +16,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
}); });
const hostDetails = data?.data?.bittorrent?.client?.host; const hostDetails = data?.data?.bittorrent?.client?.host;
// connect to qbittorrent client // connect to qbittorrent client
const { data: connectionDetails } = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/connect",
method: "POST",
data: hostDetails,
}),
enabled: !!hostDetails,
});
// get qbittorrent client info // get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({ const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"], queryKey: ["qbittorrentClientInfo"],
@@ -34,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({

View File

@@ -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";
@@ -37,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</>,

View File

@@ -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>

View File

@@ -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 <></>;

View File

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

View File

@@ -10,6 +10,7 @@ export const WantedComics = (props): ReactElement => {
const { const {
data: wantedComics, data: wantedComics,
isSuccess, isSuccess,
isFetched,
isError, isError,
isLoading, isLoading,
} = useQuery({ } = useQuery({
@@ -41,6 +42,7 @@ export const WantedComics = (props): ReactElement => {
minWidth: 350, minWidth: 350,
accessorFn: (data) => data, accessorFn: (data) => data,
cell: (value) => { cell: (value) => {
console.log("ASDASd", value);
const row = value.getValue()._source; const row = value.getValue()._source;
return row && <MetadataPanel data={row} />; return row && <MetadataPanel data={row} />;
}, },
@@ -172,7 +174,7 @@ export const WantedComics = (props): ReactElement => {
</div> </div>
</div> </div>
</header> </header>
{isSuccess ? ( {isSuccess && wantedComics?.data.hits?.hits ? (
<div> <div>
<div className="library"> <div className="library">
<T2Table <T2Table

View File

@@ -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;

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ interface IMetadatPanelProps {
containerStyle: any; containerStyle: any;
} }
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
console.log(props);
const { const {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,

View File

@@ -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;

View File

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

View 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;

View File

@@ -90,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`,
});

View File

@@ -57,7 +57,7 @@
"displayName": "Prowlarr", "displayName": "Prowlarr",
"children": [ "children": [
{ {
"id": "prowlarr-connection", "id": "prwlr-connection",
"displayName": "Connection" "displayName": "Connection"
}, },
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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.url = data.comicvine.image.small_url;
coverFile.comicvine.issueName = data.comicvine.name; coverFile.comicvine.issueName = data.comicvine.name;
coverFile.comicvine.publisher = data.comicvine.volumeInformation.publisher; coverFile.comicvine.publisher = data.comicvine.publisher.name;
} }
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;
};

View File

@@ -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];
}; };

View File

@@ -3,31 +3,15 @@ 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";
/* 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 Scraping status
comicvine: { comicvine: {
scrapingStatus: "", scrapingStatus: "",
@@ -126,11 +110,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,
}, },
})); }));
}); });
@@ -154,7 +139,6 @@ socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
status: "drained", status: "drained",
}, },
})); }));
console.log("a", queryClient);
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] }); queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
}); });
@@ -167,105 +151,3 @@ socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
}, },
})); }));
}); });
/**
* Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection
* @returns Initialized AirDC++ connection socket instance
*/
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
try {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
setState({
airDCPPSessionInformation,
});
// Set up event listeners
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
console.log(downloadProgressData);
setState({
airDCPPDownloadTick: downloadProgressData,
});
},
);
initializedAirDCPPSocket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
count += 1;
}
}
},
);
return initializedAirDCPPSocket;
} catch (error) {
console.error(error);
}
};
// 1. get settings from mongo
const { data } = await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
});
const directConnectConfiguration = data?.directConnect?.client.host;
// 2. If available, init AirDC++ Socket with those settings
if (!isNil(directConnectConfiguration)) {
const airDCPPSocketInstance = await initializeAirDCPPSocket(
directConnectConfiguration,
);
setState({
airDCPPSocketInstance,
airDCPPClientConfiguration: directConnectConfiguration,
});
} else {
console.log("problem");
}

640
yarn.lock

File diff suppressed because it is too large Load Diff