Compare commits

...

51 Commits

Author SHA1 Message Date
ea66419f33 🛠 changed import to account for graphql 2026-02-24 13:39:50 -05:00
dc014a08ce [chore] moving things around in prep for graphql 2025-11-27 00:28:53 -05:00
90d6562f45 🔧 Removed useless import 2025-09-26 10:53:48 -04:00
3563fef461 💅🏼 Prettified Library Table view 2025-07-22 23:34:01 -04:00
2968987c6b 💅🏼 Prettified Volumes search results from CV 2025-07-22 18:57:24 -04:00
924ffae07e 💅🏼 Prettifying search results 2025-07-22 18:46:25 -04:00
80f0ced0b0 🐳 Added Docker vars in the UI 2025-07-14 12:06:04 -04:00
dependabot[bot]
0def39cd73 Bump @babel/helpers from 7.23.9 to 7.27.4 (#136) 2025-06-04 07:06:07 -04:00
dependabot[bot]
1c60837ec9 Bump react-router from 6.21.3 to 7.5.2 (#132) 2025-06-04 07:05:53 -04:00
dependabot[bot]
d42a700cec Bump axios from 1.7.4 to 1.8.2 (#134) 2025-06-04 07:05:40 -04:00
dependabot[bot]
40fd8ede5d Bump vite from 5.4.12 to 5.4.19 (#135) 2025-06-04 07:05:28 -04:00
dependabot[bot]
24b09c9c5e Bump tar-fs from 2.1.1 to 2.1.3 (#133) 2025-06-04 07:03:29 -04:00
c176dab78b 🧾 Modernizing the table 2025-06-03 22:41:33 -04:00
c0b189c9e6 🧾 Modernizing tables 2025-06-03 22:33:49 -04:00
f3333b5c2c ⬇️ Fixing AirDC++ download integration 2025-06-03 21:56:55 -04:00
2ce90d94c0 🧦 Refactored socket store in zustand 2025-05-18 18:02:21 -04:00
0e445ba3d4 🔧 Fixed LoCG pull list scraping 2025-05-06 18:17:30 -04:00
9c5fb93d5b 🐳 Updated Dockerfile 2025-02-25 16:35:39 -05:00
338a46224d 🔧 Added an interface for ComicDetail 2025-02-20 17:34:21 -05:00
dependabot[bot]
0615d08e7d Bump vite from 5.2.14 to 5.4.12 (#127)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.14 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/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>
2025-02-17 16:29:57 -05:00
21a127509b Airdcpp regression (#123)
* 🔧 Fixing broken AirDCPP search

* 🔧 Fixing broken DC++ downloads

* 🔧 Todo to move method out to core service

* 🔧 Fixing the DC++ download bundles

* 🔧 Bundles endpoint integration

* 🔧 Fixed the download bundles page

*  Added an active hub badge to DC++ search

* 🔧 Fixing autodownload functionality

* 🔧 Fixed PullList source

---------

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>
2025-02-17 16:27:48 -05:00
dependabot[bot]
e92b86792e Bump axios from 1.6.8 to 1.7.4 (#121)
Bumps [axios](https://github.com/axios/axios) from 1.6.8 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.8...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  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-10-28 12:38:25 -04:00
dependabot[bot]
0f9bbd8dc1 Bump micromatch from 4.0.5 to 4.0.8 (#115)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-13 23:45:14 -04:00
dependabot[bot]
2786f0e2a4 Bump vite from 5.2.7 to 5.2.14 (#116)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.7 to 5.2.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.2.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.14/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-10-13 23:45:03 -04:00
dependabot[bot]
8dcf643444 Bump express from 4.19.2 to 4.20.0 (#118)
Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.20.0.
- [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.19.2...4.20.0)

---
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-10-13 23:44:47 -04:00
628e1f72e2 🖌️ Prettifying the form 2024-07-14 09:45:07 -04:00
f4c498bce3 🔧 Fixed caching issue with DC++ Hubs settings page 2024-07-14 00:51:08 -04:00
dependabot[bot]
bf406c8b6b Bump braces from 3.0.2 to 3.0.3 (#113)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-02 22:11:31 -04:00
dependabot[bot]
786ced6c21 Bump ws from 6.2.2 to 6.2.3 (#114)
Bumps [ws](https://github.com/websockets/ws) from 6.2.2 to 6.2.3.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/6.2.2...6.2.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-02 22:11:23 -04:00
c6c3f2eaf7 🗒️ Added scaffold for the toast notification 2024-07-02 22:10:16 -04:00
e2f1d5a307 🏗️ Added react-toastify 2024-06-13 14:09:15 -04:00
2879df114d 🔧 kafka-powered search + download 2024-06-03 17:12:49 -04:00
2c66e2f6af 🐳 Explicitly installing sass 2024-05-17 10:36:41 -04:00
217df2f899 Comicvine integration improvements I (#112)
* ️ Refactored VolumeDetail page to use react-query

* 🎨 Added some icons to tabs

* 📚 Wired up story arc fetching

*  Added status checks

* 🍇 Added some integration for issues

* 🔍 Improvements to CV search results

* 🔍 Refining CV search UX

* 🌍 Added i18n lib

* 🔍 CV search metadata wrangling

* 🔧 Refactored Wanted component

Included # of issues in a wanted volume

* 🔧 Refactoring DC++ search/download

* 🔧 Refactored AirDC++ init in store

* 🏗️ Automatic downloads WIP

* 🏗️ Modified the Dockerfile
2024-05-17 10:04:06 -04:00
9ab15df0a8 Comicvine integration improvements (#109)
* ️ Refactored VolumeDetail page to use react-query

* 🎨 Added some icons to tabs

* 📚 Wired up story arc fetching

*  Added status checks

* 🍇 Added some integration for issues

* 🔍 Improvements to CV search results

* 🔍 Refining CV search UX

* 🌍 Added i18n lib

* 🔍 CV search metadata wrangling

* 🔧 Refactored Wanted component

Included # of issues in a wanted volume

* 🔧 Refactoring DC++ search/download

* 🔧 Refactored AirDC++ init in store

* 🏗️ Automatic downloads WIP

* 🏗️ Modified the Dockerfile
2024-05-11 18:51:28 -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
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
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
c03f706e9d Dark mode 2 (#100)
* 🗂️ Added tab icons and styles

* 🪑 Styled download panel table

* 🪑 Cleaned up the DC++ search results table

* 🪑 Many changes to DC++ downloads table

* 🏗️ Wired up search with RQ

* 🏗️ Changes to ComicDetail section

* 🔧 Fixing table styes

* 🏗 Fixed the archive ops panel

* 🔧 Tweaked Archive ops further

* 🃏 Styling the action menu

* 🧩 CV Match panel refactor WIP

* 🏗️ Refactored the action menu

* 🤼 Cleaning up CV match panel

* 🏗️ Refactored the scored matches

* 🤼 Revamped CV match panel UX

* 🖌️ Styling tweaks to the side panel

* 🖌️ Cleaned up the form

* 🏗️ Refactored the search form

* 🤐 Added a uncompress indicator

* 🏗️ Fix for encoding # in URIs

* 🔧 Fixed # symbol handling in URLs

* 🔧 Started work on Edit Metadata panel

* 🔎 Added a check for existing uncompressed archives

* 🏗️ Settings styling tweaks

* 🏗️ Fixed invalidation of archiveOps

* 🏗️ Fixed an invalidation query on DC++ download panel

* 🏗️ Fixed CV-sourced Volume info panel

* 🏗️ Fixed volume group card stacks on Dashboard

* 🔍 Fixing CV search page

* 🏗️ Refactoring Volume groups and wanted panel

* 🏗️ Cleaning up useless files

* 🛝 Added keen-slider for pull list

* 🏗️ Abstracted heading/subheading into Header

* 🏗️ Continued refactoring of PullList, Volumes etc.

* 📆 Wired up the datepicker to LoCG pull list
2024-02-06 05:58:56 -05:00
4f49e538a8 Dark mode refactor (#98)
* 🏗️ Acquisition Panel refactor WIP

* 🔧 Formatted the search query box

* 🔧 Implementing download method

* 🏗️ Refactored the AirDC++ download panel

* 🌜 Initial Dark Mode support

* 🌜 Trying dark mode on the react-select

* Update App.scss

* 🏗️ Migrating Navbar to TailwindCSS

* 🖼️ Added solar icons

* 🔧 Added solar icons

* 🔧 Added code for dark mode toggle

* 🏗️ Wiring up the dark mode toggle

* 🌜 Added Dark mode to the body

* 🏗️ Building out the import page

* 🪑 Cleaning up the table styles

* 🏗️ Cleaned up past imports table

* 🏗️ Refactored Import socket events

* 🏗️ Refactored the card grid on dashboard

* 🏗️ Building variants for Cards

* 🏗️ Added a horizontal medium variant

* 🏗️ Cleaning up forms and cards

* 🔧 Styling form inputs

* 🏗️ Form refactor

* 🔠 Added a monospace font

* 🪑 Refactoring the table

* 🧹 Formatting in connection confirmation panels

* 🏗️ Refactoring table for library

* 🏗️ Added icons and details to metadata

* 🏗️ Cleaned the table further

* 🏗️ Fixed fonts, and comic detail page first draft

*  Removing yarn.lockfile
2023-12-21 16:23:29 -05:00
97 changed files with 8006 additions and 7098 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,19 +1,36 @@
FROM node:18.15.0-alpine # Use Node.js 22 as the base image
FROM node:22-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>" LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Set the working directory inside the container
WORKDIR /threetwo WORKDIR /threetwo
COPY package.json ./ # Copy package.json and yarn.lock to leverage Docker cache
COPY yarn.lock ./ COPY package.json 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 (for node-sass)
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 \
RUN yarn --ignore-engines g++ \
make \
python3 \
autoconf \
automake \
libtool \
nasm \
git
# Install node modules
RUN yarn install --ignore-engines
# Explicitly install sass
RUN yarn add -D sass
# Copy the rest of the application files into the container
COPY . . COPY . .
# Expose the application port (default for Vite)
EXPOSE 5173 EXPOSE 5173
ENTRYPOINT [ "npm", "start" ] # Start the application with yarn
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,8 +55,8 @@ 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
@@ -55,8 +67,6 @@ For debugging and troubleshooting, you can run this app locally using these step
Your comics go in the `comics` directory at the root of this project. Your comics go in the `comics` directory at the root of this project.
## Contribution Guidelines ## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md) See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

View File

@@ -1,16 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Three Two!</title> <title>Three Two!</title>
</head> </head>
<body> <body class="dark:bg-slate-600">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/client/index.tsx"></script> <script type="module" src="/src/client/index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,7 +1,7 @@
{ {
"name": "threetwo", "name": "threetwo",
"version": "0.0.2", "version": "0.1.0",
"description": "ThreeTwo! A comic book curator.", "description": "ThreeTwo! A good comic book curator.",
"main": "server/index.js", "main": "server/index.js",
"typings": "server/index.js", "typings": "server/index.js",
"scripts": { "scripts": {
@@ -18,64 +18,72 @@
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.1",
"@floating-ui/react": "^0.26.12",
"@floating-ui/react-dom": "^2.0.8",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@redux-devtools/extension": "^3.2.5", "@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5", "@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^3.1.0", "@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.8.2",
"axios-cache-interceptor": "^1.0.1", "axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0", "axios-rate-limit": "^1.3.0",
"babel-plugin-styled-components": "^2.1.4", "babel-plugin-styled-components": "^2.1.4",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"ellipsize": "^0.5.1", "ellipsize": "^0.5.1",
"express": "^4.17.1", "express": "^4.20.0",
"filename-parser": "^1.0.2", "filename-parser": "^1.0.2",
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3",
"graphql": "^16.0.0",
"graphql-request": "^7.2.0",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"i18next": "^23.11.1",
"i18next-browser-languagedetector": "^7.2.1",
"i18next-http-backend": "^2.5.0",
"immer": "^10.0.3", "immer": "^10.0.3",
"jsdoc": "^3.6.10", "jsdoc": "^3.6.10",
"keen-slider": "^6.8.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qs": "^6.10.5", "qs": "^6.10.5",
"react": "^18.2.0", "react": "^18.3.1",
"react-collapsible": "^2.9.0", "react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0", "react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.0", "react-day-picker": "^8.10.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4", "react-final-form-arrays": "^3.1.4",
"react-i18next": "^14.1.0",
"react-loader-spinner": "^4.0.0", "react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-router": "^6.9.0", "react-router": "^7.5.2",
"react-router-dom": "^6.9.0", "react-router-dom": "^6.9.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2", "react-select-async-paginate": "^0.7.2",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0", "react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1", "react-toastify": "^10.0.5",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2", "socket.io-client": "^4.3.2",
"styled-components": "^6.1.0", "styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.5", "vite": "^5.4.19",
"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"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/solar": "^1.1.8",
"@iconify/tailwind": "^0.1.4",
"@storybook/addon-essentials": "^7.4.1", "@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-interactions": "^7.4.1", "@storybook/addon-interactions": "^7.4.1",
"@storybook/addon-links": "^7.4.1", "@storybook/addon-links": "^7.4.1",
@@ -95,8 +103,8 @@
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-redux": "^7.1.25", "@types/react-redux": "^7.1.25",
"autoprefixer": "^10.4.16",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bulma": "^0.9.4",
"docdash": "^2.0.2", "docdash": "^2.0.2",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
@@ -107,16 +115,22 @@
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0", "eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.13", "eslint-plugin-storybook": "^0.6.13",
"express": "^4.17.1", "express": "^4.20.0",
"install": "^0.13.0", "install": "^0.13.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"postcss": "^8.4.32",
"postcss-import": "^15.1.0",
"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",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6" "typescript": "^5.1.6"
},
"resolutions": {
"jackspeak": "2.1.1"
} }
} }

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -1,637 +1,15 @@
@import "/node_modules/bulma/bulma.sass"; @tailwind base;
$fa-font-path: "/node_modules/@fortawesome/fontawesome-free/webfonts"; @tailwind components;
@import "/node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss"; @tailwind utilities;
@import "/node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
@import "/node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
$bg-color: yellow;
$border-color: red;
$volume-color: #fdecd1; @layer base {
$issue-color: #f2f1f9; @font-face {
$size-8: 0.9rem; font-family: "PP Object Sans Regular";
$size-9: 0.7rem; src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
$flexSize: 4em; }
$boxSpacing: 1em;
$colorText: #404646;
body {
background: #fffcc;
}
.is-size-8 {
font-size: $size-8;
}
.is-size-9 { @font-face {
font-size: $size-9; font-family: "Hasklig Regular";
} src: url("/fonts/Hasklig-Regular.otf") format("opentype");
.small-tag {
align-items: center;
background-color: #fff6de;
border-radius: 4px;
color: #4a4a4a;
display: inline-flex;
font-size: $size-9;
height: 1.5em;
justify-content: center;
line-height: 1.5;
padding-left: 0.55em;
padding-right: 0.55em;
white-space: nowrap;
}
// global style overrides
pre {
border-radius: 0.5rem;
}
.container {
margin-top: 2em;
}
.app {
font-family: helvetica, arial, sans-serif;
padding: 2em;
border: 5px solid $border-color;
p {
background-color: $bg-color;
}
}
// Navbar
.navbar {
border-bottom: 1px solid #f2f1f9;
.download-progress-meter {
margin-left: -300px;
min-width: 500px;
}
.airdcpp-status {
min-width: 300px;
line-height: 1.7rem;
}
body {
background: #454a59;
}
body {
background: #454a59;
}
.pulsating-circle {
position: relative;
left: -120%;
top: 20%;
transform: translateX(-50%) translateY(-50%);
width: 15px;
height: 15px;
&:before {
content: "";
position: relative;
display: block;
width: 300%;
height: 300%;
box-sizing: border-box;
margin-left: -100%;
margin-top: -100%;
border-radius: 45px;
background-color: #01a4e9;
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
&:after {
content: "";
position: absolute;
left: 0;
top: 0;
display: block;
width: 100%;
height: 100%;
background-color: green;
border-radius: 15px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite;
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.33);
}
80%,
100% {
opacity: 0;
}
}
@keyframes pulse-dot {
0% {
transform: scale(0.8);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.8);
}
}
}
.navbar-item.is-mega {
position: static;
.is-mega-menu-title {
margin-bottom: 0;
padding: 0.375rem 1rem;
}
}
// Dashboard
// slick slider overrides
.slick-slider {
margin-left: -10px;
.slick-list {
padding: 0 0px 15px 10px;
}
}
.recent-comics-container {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -22px; /* gutter size offset */
width: auto;
.recent-comics-column {
padding-left: 22px; /* gutter size */
background-clip: padding-box;
& > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
}
}
.volumes-container {
.stack {
display: inline-block;
border-radius: 0.5rem;
box-shadow:
/* The top layer shadow */ 0 -1px 1px rgba(0, 0, 0, 0.15),
/* The second layer */ 0 -10px 0 -5px #eee,
/* The second layer shadow */ 0 -10px 1px -4px rgba(0, 0, 0, 0.15),
/* The third layer */ 0 -20px 0 -10px #eee,
/* The third layer shadow */ 0 -20px 1px -9px rgba(0, 0, 0, 0.15);
img {
height: auto;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.stack-title {
margin-bottom: 0.4rem;
}
.content {
margin: -5px 0 0 0;
padding: 0.5rem 1rem;
border-bottom-left-radius: 0.25rem;
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
border-bottom-right-radius: 0.25rem;
}
}
.volumes-grid {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -30px; /* gutter size offset */
width: auto;
}
.volumes-grid-column {
padding-left: 22px; /* gutter size */
background-clip: padding-box;
& > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
}
}
.min {
overflow: visible;
margin: auto;
.tag__custom {
height: auto !important;
padding: 0.3rem;
white-space: unset !important;
width: 100%;
background-color: #effaf5;
color: #257953;
}
.tags {
display: inline;
margin-right: 5px;
margin-left: 5px;
&:first-child {
margin-left: 0;
}
}
pre {
border-radius: 0.4em;
margin: 10px 0 10px 0;
white-space: pre-wrap;
}
}
.generic-card {
display: inline-block;
background-color: #fff;
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
border-bottom-left-radius: 0.4rem;
border-bottom-right-radius: 0.4rem;
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
.green-border {
border: 1px dotted #168b64;
border-radius: 0.4rem;
}
.truncate {
width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partial-rounded-card-image {
figure {
display: flex;
img {
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.rounded-card-image {
figure {
display: flex;
img {
border-radius: 0.4rem;
}
}
}
.card-content {
.card-title {
margin-bottom: 0.4rem;
}
.custom-icon,
i {
margin: 4px 4px 4px 0;
}
padding: 0.5rem 1rem;
}
}
.card-container {
// display: grid;
// grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
// column-gap: 0.5em;
// row-gap: 1.2em;
.card {
margin: 0 0 15px 0;
.partial-rounded-card-image {
img {
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.rounded-card-image {
border-radius: 0.4rem;
}
.is-horizontal {
// margin: $boxSpacing / 2;
border-radius: 1.5em;
height: $flexSize;
max-width: $flexSize * 3;
flex: 1 1 auto;
display: flex;
.card-image {
// leaving this here... for posterity
img.image {
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
height: 100%;
max-width: $flexSize * 1.3;
object-fit: cover;
flex: 1 1 auto;
}
img.cropped-image {
width: 70px;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
height: 64px;
object-fit: cover;
object-position: 100% 0;
// flex: 1 1 auto;
}
}
}
.card-content {
align-self: top;
flex: 1;
padding-left: 0.7em;
padding-top: 0.4em;
padding-bottom: 0em;
}
}
}
// raw file details
.raw-file-details {
padding: 1rem;
background-color: beige;
border-radius: 0.5rem;
}
.comic-viewer {
border: 1px solid red;
}
// comicvine metadata
.comicvine-metadata {
background-color: #f2f1f9;
padding: 0.8rem;
border-radius: 0.5rem;
}
.issue-metadata {
background-color: #fbffee;
padding: 0.8em;
border-radius: 0.5rem;
.name {
font-size: 0.95rem;
color: #4a4f50;
}
}
.comicInfo-metadata {
background-color: #f7ebdd;
padding: 0.8rem;
border-radius: 0.5rem;
}
// Comic Detail
.comic-detail {
dl {
dd {
margin: 0;
}
}
.button {
.airdcpp-text {
margin: 0 0 0 0.2rem;
}
}
}
// AirDC++ search results
.dupe-search-result {
background: lavender;
}
// Search
.search {
.main-search-bar {
border: 0;
border-bottom: 1px solid #999;
border-radius: 0;
outline: 0;
background: transparent;
box-shadow: none;
}
}
// Library
.header-area {
width: 100%;
padding: 25px 0 15px 0;
position: sticky;
z-index: 9999;
background: #fffffc;
top: 50px;
}
.library {
.table-controls {
background: #fffffc;
justify-content: space-between;
position: sticky;
top: 126px;
padding-bottom: 10px;
}
.pagination {
margin: 0;
background: #fffffc;
}
table {
border-collapse: separate;
width: 100%;
thead {
position: sticky;
top: 250px;
z-index: 1;
background: #fffffc;
min-height: 130px;
}
tr {
td {
border: 0 none;
.card {
margin: 8px 0 7px 0;
.name {
margin: 0 0 4px 0;
}
}
}
}
tbody {
padding: 10px 0 0 0;
}
}
}
// Comic Detail
.control-palette {
background-color: #fff6de;
display: inline-block;
i {
display: flex;
justify-content: center;
align-items: center;
// padding: 1.5rem 2rem;
}
}
// airdcpp downloads tab
.tabs {
.download-icon-labels {
.downloads-count {
margin: 0 1em -1px 0.4em;
border: 1px solid #ccc;
}
}
.download-tab-name {
}
}
// drawer content padding override
.slide-pane__content {
padding: 24px 12px;
}
.slide-pane__header {
margin-top: 3.5rem;
}
.comic-vine-match-drawer {
// comic detail drawer
.search-criteria-card {
width: 100%;
.card-content {
padding: 10px;
.ant-divider-horizontal {
margin: 12px 0;
}
}
}
.field {
margin: 5px 0 0 0;
}
}
// Volume detail
.volume-details {
.is-volume-related {
$tag-background-color: $volume-color;
}
.issues-container {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -10px; /* gutter size offset */
width: auto;
.issues-column {
max-width: 102px;
margin: 10px;
background-clip: padding-box;
& > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
}
}
}
// Potential issue matches in library slideout panel
.potential-matches-container {
.potential-issue-match {
border-radius: 0.3rem;
background-color: beige;
padding: 10px;
pre {
padding: 5px;
background-color: transparent;
border-radius: 0.3rem;
white-space: pre-wrap; /* Since CSS 2.1 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word;
}
.generic-card {
max-width: 90px;
}
}
}
// comicvine search results
.search-results-container {
margin: 15px 0 0 0;
overflow: hidden;
.search-result {
margin: 0 0 10px 0;
padding: 1em;
border-radius: 10px;
background: #f2f1f9;
.cover-image {
border-radius: 5px;
}
.search-result-details {
.score {
float: right;
}
}
.volume-information {
margin-top: -2.5em;
width: 80%;
background: #fdecd1;
border-radius: 10px;
}
.vertical-line {
position: relative;
top: -25px;
left: 1.5rem;
border: 2px dotted #ccc;
width: 20px;
min-height: 35px;
border-color: transparent transparent #f3a22d #f3a22d;
border-bottom-left-radius: 10px;
}
}
}
// Library grid
.my-masonry-grid {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -30px; /* gutter size offset */
width: auto;
}
.my-masonry-grid_column {
padding-left: 30px; /* gutter size */
background-clip: padding-box;
}
.my-masonry-grid_column > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
// progress
.progress-indicator-container {
height: 100%;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
.indicator {
padding: 5px;
width: 120px;
height: 120px;
} }
} }

View File

@@ -1,13 +1,20 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useEffect } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import Navbar from "./shared/Navbar"; import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
import "../assets/scss/App.scss"; import "../assets/scss/App.scss";
import { useStore } from "../store";
export const App = (): ReactElement => { export const App = (): ReactElement => {
useEffect(() => {
useStore.getState().getSocket("/"); // Connect to the base namespace
}, []);
return ( return (
<> <>
<Navbar /> <Navbar2 />
<Outlet /> <Outlet />
<ToastContainer stacked hideProgressBar />
</> </>
); );
}; };

View File

@@ -1,319 +1,519 @@
import React, { useCallback, ReactElement, useEffect, useState } from "react"; import React, {
import { useCallback,
downloadAirDCPPItem, ReactElement,
getBundlesForComic, useEffect,
sleep, useRef,
} from "../../actions/airdcpp.actions"; useState,
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; } from "react";
import { RootState, SearchInstance } from "threetwo-ui-typings"; import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils"; import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
import type { Socket } from "socket.io-client";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
comicObjectId: any; comicObjectId: string;
comicObject: any; comicObject: any;
settings: any; settings: any;
} }
interface AirDCPPConfig {
protocol: string;
hostname: string;
username: string;
password: string;
}
interface SearchResult {
id: string;
name: string;
type: {
id: string;
str: string;
};
size: number;
slots: {
total: number;
free: number;
};
users: {
user: {
nicks: string;
flags: string[];
};
};
dupe?: any;
}
interface SearchInstanceData {
id: number;
owner: string;
expires_in: number;
}
interface SearchInfo {
query: {
pattern: string;
extensions: string[];
file_type: string;
};
}
interface Hub {
hub_url: string;
identity: {
name: string;
};
value: string;
}
interface SearchFormValues {
issueName: string;
}
export const AcquisitionPanel = ( export const AcquisitionPanel = (
props: IAcquisitionPanelProps, props: IAcquisitionPanelProps,
): ReactElement => { ): ReactElement => {
const { const socketRef = useRef<Socket>();
airDCPPSocketInstance, const queryClient = useQueryClient();
airDCPPClientConfiguration, const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
airDCPPSessionInformation,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
})),
);
/** const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null);
* Get the hubs list from an AirDCPP Socket const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]);
*/ const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const { data: hubs } = useQuery({ const [isSearching, setIsSearching] = useState(false);
queryKey: ["hubs"], const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<SearchInstanceData | null>(null);
queryFn: async () => await airDCPPSocketInstance.get(`hubs`), const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<SearchInfo | null>(null);
}); const [searchError, setSearchError] = useState<string | null>(null);
const { comicObjectId } = props;
const issueName = props.query.issue.name || ""; const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Selectors for picking state // Search timeout duration in milliseconds (30 seconds)
// const airDCPPSearchResults = useSelector((state: RootState) => { const SEARCH_TIMEOUT_MS = 30000;
// return state.airdcpp.searchResults;
// });
// const isAirDCPPSearchInProgress = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
// );
// const searchInfo = useSelector(
// (state: RootState) => state.airdcpp.searchInfo,
// );
// const searchInstance: SearchInstance = useSelector(
// (state: RootState) => state.airdcpp.searchInstance,
// );
// const settings = useSelector((state: RootState) => state.settings.data);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
// Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that
// All the user has to do is hit "Search AirDC++"
useEffect(() => { useEffect(() => {
// AirDC++ search query const socket = useStore.getState().getSocket("manual");
const dcppSearchQuery = { socketRef.current = socket;
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`, // --- Handlers ---
extensions: ["cbz", "cbr", "cb7"], const handleResultAdded = ({ result }: { result: SearchResult }) => {
}, setAirDCPPSearchResults((prev) =>
hub_urls: map(hubs, (item) => item.value), prev.some((r) => r.id === result.id) ? prev : [...prev, result],
priority: 5, );
}; };
setDcppQuery(dcppSearchQuery);
}, []);
const search = async (data: SearchData, ADCPPSocket: any) => { const handleResultUpdated = ({ result }: { result: SearchResult }) => {
try { setAirDCPPSearchResults((prev) => {
if (!ADCPPSocket.isConnected()) { const idx = prev.findIndex((r) => r.id === result.id);
await ADCPPSocket(); if (idx === -1) return prev;
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
const next = [...prev];
next[idx] = result;
return next;
});
};
const handleSearchInitiated = (data: { instance: SearchInstanceData }) => {
setAirDCPPSearchInstance(data.instance);
setIsSearching(true);
setSearchError(null);
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
} }
const instance: SearchInstance = await ADCPPSocket.post("search");
// dispatch({
// type: AIRDCPP_SEARCH_IN_PROGRESS,
// });
// We want to get notified about every new result in order to make the user experience better // Set a timeout to stop searching after SEARCH_TIMEOUT_MS
await ADCPPSocket.addListener( searchTimeoutRef.current = setTimeout(() => {
`search`, setIsSearching(false);
"search_result_added", console.log(`Search timeout reached after ${SEARCH_TIMEOUT_MS / 1000} seconds`);
async (groupedResult) => { }, SEARCH_TIMEOUT_MS);
// ...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]); const handleSearchesSent = (data: { searchInfo: SearchInfo }) => {
setAirDCPPSearchInfo(data.searchInfo);
};
const handleSearchError = (error: { message: string }) => {
setSearchError(error.message || "Search failed");
setIsSearching(false);
// Clear timeout on error
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
};
const handleSearchCompleted = () => {
setIsSearching(false);
// Clear timeout when search completes
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
};
// --- Subscribe once ---
socket.on("searchResultAdded", handleResultAdded);
socket.on("searchResultUpdated", handleResultUpdated);
socket.on("searchInitiated", handleSearchInitiated);
socket.on("searchesSent", handleSearchesSent);
socket.on("searchError", handleSearchError);
socket.on("searchCompleted", handleSearchCompleted);
return () => {
socket.off("searchResultAdded", handleResultAdded);
socket.off("searchResultUpdated", handleResultUpdated);
socket.off("searchInitiated", handleSearchInitiated);
socket.off("searchesSent", handleSearchesSent);
socket.off("searchError", handleSearchError);
socket.off("searchCompleted", handleSearchCompleted);
// Clean up timeout on unmount
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [SEARCH_TIMEOUT_MS]);
const {
data: settings,
isLoading: isLoadingSettings,
isError: isSettingsError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
method: "GET",
}),
});
const { data: hubs, isLoading: isLoadingHubs } = useQuery({
queryKey: ["hubs", settings?.data.directConnect?.client?.host],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
}, },
instance.id, }),
); enabled: !!settings?.data?.directConnect?.client?.host,
});
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories // Get AirDC++ config from settings
await ADCPPSocket.addListener( const airDCPPConfig: AirDCPPConfig | null = settings?.data?.directConnect?.client
`search`, ? {
"search_result_updated", protocol: settings.data.directConnect.client.protocol || "ws",
async (groupedResult) => { hostname: typeof settings.data.directConnect.client.host === 'string'
// ...update properties of the existing result in the UI ? settings.data.directConnect.client.host
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex( : `${settings.data.directConnect.client.host?.hostname || 'localhost'}:${settings.data.directConnect.client.host?.port || '5600'}`,
(bundle) => bundle.result.id === groupedResult.result.id, username: settings.data.directConnect.client.username || "admin",
); password: settings.data.directConnect.client.password || "password",
const updatedState = [...airDCPPSearchResults]; }
if ( : null;
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) { useEffect(() => {
updatedState[bundleToUpdateIndex] = groupedResult; if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
} const dcppSearchQuery = {
setAirDCPPSearchResults((state) => [...state, ...updatedState]); query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
}, },
instance.id, hub_urls: map(hubs.data, (item) => item.value),
); priority: 5,
};
// 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) setDcppQuery(dcppSearchQuery as any);
// 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}`,
);
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
// dispatch({
// type: AIRDCPP_HUB_SEARCHES_SENT,
// searchInfo,
// instance,
// });
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
} }
}, [hubs, sanitizedIssueName]);
const search = async (searchData: any) => {
if (!airDCPPConfig) {
setSearchError("AirDC++ configuration not found in settings");
return;
}
if (!socketRef.current) {
setSearchError("Socket connection not available");
return;
}
setAirDCPPSearchResults([]);
setIsSearching(true);
setSearchError(null);
socketRef.current.emit("call", "socket.search", {
query: searchData,
namespace: "/manual",
config: airDCPPConfig,
});
}; };
const getDCPPSearchResults = async (searchQuery) => {
const download = async (
searchInstanceId: number,
resultId: string,
comicObjectId: string,
name: string,
size: number,
type: SearchResult["type"],
config: AirDCPPConfig,
): Promise<void> => {
if (!socketRef.current) {
console.error("Socket connection not available");
return;
}
socketRef.current.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => console.log("Download initiated:", data),
);
};
const getDCPPSearchResults = async (searchQuery: SearchFormValues) => {
if (!searchQuery.issueName || searchQuery.issueName.trim() === "") {
setSearchError("Please enter a search term");
return;
}
if (!hubs?.data || !Array.isArray(hubs.data) || hubs.data.length === 0) {
setSearchError("No hubs configured");
return;
}
const manualQuery = { const manualQuery = {
query: { query: {
pattern: `${searchQuery.issueName}`, pattern: `${searchQuery.issueName.trim()}`,
extensions: ["cbz", "cbr", "cb7"], extensions: ["cbz", "cbr", "cb7"],
}, },
hub_urls: map(hubs, (hub) => hub.hub_url), hub_urls: [hubs.data[0].hub_url],
priority: 5, priority: 5,
}; };
search(manualQuery, airDCPPSocketInstance); search(manualQuery);
}; };
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
// dispatch(
// downloadAirDCPPItem(
// searchInstanceId,
// resultId,
// props.comicObjectId,
// name,
// size,
// type,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
// this is to update the download count badge on the downloads tab
// dispatch(
// getBundlesForComic(
// props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
},
[],
);
console.log("yaman", airDCPPSearchResults);
return ( return (
<> <>
<div className="comic-detail columns"> <div className="mt-5 mb-3">
{!isEmpty(airDCPPSocketInstance) ? ( {isLoadingSettings || isLoadingHubs ? (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin" />
Loading configuration...
</div>
) : !isEmpty(hubs?.data) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
issueName, issueName,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form <form onSubmit={handleSubmit}>
onSubmit={handleSubmit} <Field name="issueName">
className="column is-three-quarters" {({ input, meta }) => {
> return (
<div className="box search"> <div className="max-w-fit">
<div className="columns"> <div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<Field name="issueName"> <div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
{({ input, meta }) => { <i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info"></span>
</div> </div>
); <input
}} {...input}
</Field> className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name"
disabled={isSearching}
/>
<div className="column"> <button
<button className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
type="submit" type="submit"
className={ disabled={isSearching}
false >
? "button is-loading is-warning" <div className="flex flex-row items-center">
: "button is-success is-light" {isSearching ? (
} <>
> <i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" />
<span className="icon is-small"> Searching...
<img src="/src/client/assets/img/airdcpp_logo.svg" /> </>
</span> ) : (
<span className="airdcpp-text">Search on AirDC++</span> <>
</button> Search DC++
</div> <div className="h-5 w-5 ml-2">
</div> <img
</div> src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</>
)}
</div>
</button>
</div>
</div>
);
}}
</Field>
</form> </form>
)} )}
/> />
) : ( ) : (
<div className="column is-three-fifths"> <article
<article className="message is-info"> role="alert"
<div className="message-body is-size-6 is-family-secondary"> className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
AirDC++ is not configured. Please configure it in{" "} >
<code>Settings &gt; AirDC++ &gt; Connection</code>. No AirDC++ hub configured. Please configure it in{" "}
</div> <code>Settings &gt; AirDC++ &gt; Hubs</code>.
</article> </article>
</div>
)} )}
</div> </div>
{/* Search Error Display */}
{searchError && (
<article
role="alert"
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-red-500 bg-red-50 p-4 dark:border-s-4 dark:border-red-600 dark:bg-red-300 dark:text-slate-600"
>
<strong>Error:</strong> {searchError}
</article>
)}
{/* configured hub */}
{!isEmpty(hubs?.data) && hubs?.data[0] && (
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
<span className="pr-1 pt-1">
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
</span>
{hubs.data[0].hub_url}
</span>
)}
{/* AirDC++ search instance details */}
{airDCPPSearchInstance &&
airDCPPSearchInfo &&
hubs?.data && (
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs.data.map((value: Hub, idx: number) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
)}
{/* AirDC++ results */} {/* AirDC++ results */}
<div className="columns"> <div className="">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( {airDCPPSearchResults.length > 0 ? (
<div className="column"> <div className="overflow-x-auto max-w-full mt-6">
<table className="table"> <table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
<thead> <thead>
<tr> <tr className="border-b border-gray-300 dark:border-slate-700">
<th>Name</th> <th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
<th>Type</th> Name
<th>Slots</th> </th>
<th>Actions</th> <th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Type
</th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Slots
</th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{map(airDCPPSearchResults, ({ result }, idx) => { {map(
return ( airDCPPSearchResults,
({ dupe, type, name, id, slots, users, size }, idx) => (
<tr <tr
key={idx} key={idx}
className={ className={
!isNil(result.dupe) ? "dupe-search-result" : "" !isNil(dupe)
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
: "border-b border-gray-200 dark:border-slate-700 text-sm"
} }
> >
<td> {/* NAME */}
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
<p className="mb-2"> <p className="mb-2">
{result.type.id === "directory" ? ( {type.id === "directory" && (
<i className="fas fa-folder"></i> <i className="fas fa-folder mr-1"></i>
) : null} )}
{ellipsize(result.name, 70)} {ellipsize(name, 45)}
</p> </p>
<dl> <dl>
<dd> <dd>
<div className="tags"> <div className="inline-flex flex-wrap gap-1">
{!isNil(result.dupe) ? ( {!isNil(dupe) && (
<span className="tag is-warning">Dupe</span> <span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
) : null} <i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
<span className="tag is-light is-info"> Dupe
{result.users.user.nicks} </span>
)}
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
{users.user.nicks}
</span> </span>
{result.users.user.flags.map((flag, idx) => ( {users.user.flags.map((flag: string, flagIdx: number) => (
<span className="tag is-light" key={idx}> <span
key={flagIdx}
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
>
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
{flag} {flag}
</span> </span>
))} ))}
@@ -321,71 +521,83 @@ export const AcquisitionPanel = (
</dd> </dd>
</dl> </dl>
</td> </td>
<td>
<span className="tag is-light is-info"> {/* TYPE */}
{result.type.id === "directory" <td className="px-2 py-3">
? "directory" <span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
: result.type.str} <i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
{type.str}
</span> </span>
</td> </td>
<td>
<div className="tags has-addons"> {/* SLOTS */}
<span className="tag is-success"> <td className="px-2 py-3">
{result.slots.free} free <span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
</span> <i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
<span className="tag is-light"> {slots.total} slots; {slots.free} free
{result.slots.total} </span>
</span>
</div>
</td> </td>
<td>
{/* ACTIONS */}
<td className="px-2 py-3">
<button <button
className="button is-small is-light is-success" className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => onClick={() => {
downloadDCPPResult( if (airDCPPSearchInstance && airDCPPConfig) {
searchInstance.id, download(
result.id, airDCPPSearchInstance.id,
result.name, id,
result.size, comicObjectId,
result.type, name,
) size,
} type,
airDCPPConfig,
);
}
}}
disabled={!airDCPPSearchInstance || !airDCPPConfig}
> >
<span className="icon"> Download
<i className="fas fa-file-download"></i> <i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
</span>
<span>Download </span>
</button> </button>
</td> </td>
</tr> </tr>
); ),
})} )}
</tbody> </tbody>
</table> </table>
</div> </div>
) : ( ) : !isSearching ? (
<div className="column is-three-fifths"> <div className="">
<article className="message is-info"> <article
<div className="message-body is-size-6 is-family-secondary"> role="alert"
<p> className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
The default search term is an auto-detected title; you may >
need to change it to get better matches if the auto-detected <div>
one doesn't work. The default search term is an auto-detected title; you may need
</p> to change it to get better matches if the auto-detected one
doesn't work.
</div> </div>
</article> </article>
<article className="message is-warning"> <article
<div className="message-body is-size-6 is-family-secondary"> role="alert"
<p className="content"> className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
Searching via <strong>AirDC++</strong> is still in >
<strong>alpha</strong>. Some searches may take arbitrarily <div>
long, or may not work at all. Searches from <code>ADCS</code> Searching via <strong>AirDC++</strong> is still in{" "}
hubs are more reliable than <code>NMDCS</code> ones. <strong>alpha</strong>. Some searches may take arbitrarily long,
</p> or may not work at all. Searches from{" "}
<code className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
</div> </div>
</article> </article>
</div> </div>
) : (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-6 p-4">
<i className="icon-[solar--refresh-bold-duotone] h-6 w-6 animate-spin" />
Searching...
</div>
)} )}
</div> </div>
</> </>

View File

@@ -1,85 +1,26 @@
import { filter, isEmpty, isNil, isUndefined } from "lodash"; import React, { ReactElement } from "react";
import React, { ReactElement, useCallback } from "react"; import Select from "react-select";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => { export const Menu = (props: any): ReactElement => {
const { data } = props; const {
const { setSlidingPanelContentId, setVisible } = props.handlers; filteredActionOptions,
const openDrawerWithCVMatches = useCallback(() => { customStyles,
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; handleActionSelection,
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; Placeholder,
} = props.configuration;
if (!isUndefined(data.rawFileDetails)) {
issueSearchQuery = refineQuery(data.rawFileDetails.name);
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
return ( return (
<Select <Select
className="basic-single"
classNamePrefix="select"
components={{ Placeholder }} components={{ Placeholder }}
placeholder={ placeholder={
<span> <span className="inline-flex flex-row items-center gap-1.5 pt-1">
<i className="fa-solid fa-list"></i> Actions <div className="w-4 h-4">
<i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Select An Action</div>
</span> </span>
} }
styles={customStyles}
name="actions" name="actions"
isSearchable={false} isSearchable={false}
options={filteredActionOptions} options={filteredActionOptions}

View File

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

View File

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

View File

@@ -12,11 +12,12 @@ import { Menu } from "./ActionMenu/Menu";
import { ArchiveOperations } from "./Tabs/ArchiveOperations"; import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { ComicInfoXML } from "./Tabs/ComicInfoXML"; import { ComicInfoXML } from "./Tabs/ComicInfoXML";
import AcquisitionPanel from "./AcquisitionPanel"; import AcquisitionPanel from "./AcquisitionPanel";
import TorrentSearchPanel from "./TorrentSearchPanel";
import DownloadsPanel from "./DownloadsPanel"; import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation"; import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash"; import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { RootState } from "threetwo-ui-typings"; import { components } from "react-select";
import "react-sliding-pane/dist/react-sliding-pane.css"; import "react-sliding-pane/dist/react-sliding-pane.css";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
@@ -27,8 +28,39 @@ import ComicViewer from "react-comic-viewer";
import { extractComicArchive } from "../../actions/fileops.actions"; import { extractComicArchive } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import axios from "axios";
import { styled } from "styled-components";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { refineQuery } from "filename-parser";
type ComicDetailProps = {}; interface ComicDetailProps {
data: {
_id: string;
rawFileDetails?: any;
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
sourcedMetadata: {
comicvine?: any;
locg?: any;
comicInfo?: any;
};
acquisition?: {
directconnect?: {
downloads?: any[];
};
torrent?: any[];
};
createdAt: string;
updatedAt: string;
};
userSettings?: any;
}
/** /**
* Component for displaying the metadata for a comic in greater detail. * Component for displaying the metadata for a comic in greater detail.
* *
@@ -47,6 +79,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
inferredMetadata, inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo }, sourcedMetadata: { comicvine, locg, comicInfo },
acquisition, acquisition,
createdAt,
updatedAt,
}, },
userSettings, userSettings,
} = data; } = data;
@@ -54,25 +88,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false); const [modalIsOpen, setIsOpen] = useState(false);
const [comicVineMatches, setComicVineMatches] = useState([]);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
// const dispatch = useDispatch(); // const dispatch = useDispatch();
const openModal = useCallback((filePath) => { const openModal = useCallback((filePath: string) => {
setIsOpen(true); setIsOpen(true);
// dispatch( // dispatch(
// extractComicArchive(filePath, { // extractComicArchive(filePath, {
@@ -85,7 +107,11 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// ); // );
}, []); }, []);
const afterOpenModal = useCallback((things) => { // overridden <SlidingPanel> with some styles
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
const afterOpenModal = useCallback((things: any) => {
// references are now sync'd and can be accessed. // references are now sync'd and can be accessed.
// subtitle.style.color = "#f00"; // subtitle.style.color = "#f00";
console.log("kolaveri", things); console.log("kolaveri", things);
@@ -96,61 +122,187 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, []); }, []);
// sliding panel init // sliding panel init
const contentForSlidingPanel = { const contentForSlidingPanel: Record<string, { content: (props?: any) => JSX.Element }> = {
CVMatches: { CVMatches: {
content: (props) => ( content: (props?: any) => (
<> <>
{/* <div className="card search-criteria-card"> <div>
<div className="card-content"> <ComicVineSearchForm data={rawFileDetails} />
<ComicVineSearchForm data={rawFileDetails} />
</div>
</div> </div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
{inferredMetadata.issue ? ( <div className="border-slate-500 border rounded-lg p-2 mt-3">
<div className="ml-3"> <p className="">Searching for:</p>
<span className="tag mr-3">{inferredMetadata.issue.name} </span> {inferredMetadata?.issue ? (
<span className="tag"> # {inferredMetadata.issue.number} </span> <>
</div> <span className="">{inferredMetadata.issue.name} </span>
) : null} <span className=""> # {inferredMetadata.issue.number} </span>
{!comicVineAPICallProgress ? ( </>
<ComicVineMatchPanel ) : null}
props={{ </div>
comicVineSearchQueryObject, <ComicVineMatchPanel
comicVineAPICallProgress, props={{
comicVineSearchResults, comicVineMatches,
comicObjectId, comicObjectId,
}} }}
/> />
) : (
<div className="progress-indicator-container">
<div className="indicator">
<Loader
type="MutatingDots"
color="#CCC"
secondaryColor="#999"
height={100}
width={100}
visible={comicVineAPICallProgress}
/>
</div>
</div>
)} */}
</> </>
), ),
}, },
editComicBookMetadata: { editComicBookMetadata: {
content: () => <EditMetadataPanel />, content: () => <EditMetadataPanel data={rawFileDetails} />,
}, },
}; };
// Actions
const fetchComicVineMatches = async (
searchPayload: any,
issueSearchQuery: any,
seriesSearchQuery: any,
) => {
try {
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
method: "POST",
data: {
format: "json",
// hack
query: issueSearchQuery.inferredIssueDetails.name
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim(),
limit: "100",
page: 1,
resources: "volume",
scorerConfiguration: {
searchParams: issueSearchQuery.inferredIssueDetails,
},
rawFileDetails: searchPayload,
},
transformResponse: (r: string) => {
const matches = JSON.parse(r);
return matches;
// return sortBy(matches, (match) => -match.score);
},
});
let matches: any = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match: any) => match);
}
const scoredMatches = matches.sort((a: any, b: any) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
console.log(err);
}
};
// Action event handlers
const openDrawerWithCVMatches = () => {
let seriesSearchQuery: any = {};
let issueSearchQuery: any = {};
if (!isUndefined(rawFileDetails)) {
issueSearchQuery = refineQuery((rawFileDetails as any).name);
} else if (!isEmpty(comicvine)) {
issueSearchQuery = refineQuery((comicvine as any).name);
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Match on ComicVine</div>
</span>
);
const editLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Edit Metadata</div>
</span>
);
const deleteLabel = (
<span className="inline-flex flex-row items-center gap-1.5">
<div className="w-4 h-4">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
</div>
<div className="text-sm">Delete Comic</div>
</span>
);
const Placeholder = (props: any) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action: any) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
menu: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base: any) => ({
...base,
color: "black",
}),
option: (base: any, { data, isDisabled, isFocused, isSelected }: any) => ({
...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}),
singleValue: (base: any) => ({
...base,
paddingTop: "0.4rem",
}),
control: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
color: "black",
border: "1px solid rgb(156, 163, 175)",
}),
};
// check for the availability of CV metadata // check for the availability of CV metadata
const isComicBookMetadataAvailable = const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation); !isUndefined(comicvine) && !isUndefined((comicvine as any)?.volumeInformation);
// check for the availability of rawFileDetails // check for the availability of rawFileDetails
const areRawFileDetailsAvailable = const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover); !isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
const { issueName, url } = determineCoverFile({ const { issueName, url } = determineCoverFile({
rawFileDetails, rawFileDetails,
@@ -170,7 +322,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 1, id: 1,
name: "Volume Information", name: "Volume Information",
icon: <i className="fa-solid fa-layer-group"></i>, icon: (
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: isComicBookMetadataAvailable ? ( content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} /> <VolumeInformation data={data.data} key={1} />
) : null, ) : null,
@@ -179,35 +333,37 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 2, id: 2,
name: "ComicInfo.xml", name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>, icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: ( content: (
<div className="columns" key={2}> <div key={2}>
<div className="column is-three-quarters"> {!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div> </div>
), ),
shouldShow: !isEmpty(comicInfo), shouldShow: !isEmpty(comicInfo),
}, },
{ {
id: 3, id: 3,
icon: <i className="fa-regular fa-file-archive"></i>, icon: (
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "Archive Operations", name: "Archive Operations",
content: <></>, content: <ArchiveOperations data={data.data} key={3} />,
/*
<ArchiveOperations data={data.data} key={3} /> */
shouldShow: areRawFileDetailsAvailable, shouldShow: areRawFileDetailsAvailable,
}, },
{ {
id: 4, id: 4,
icon: <i className="fa-solid fa-circle-nodes"></i>, icon: (
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "DC++ Search", name: "DC++ Search",
content: ( content: (
<AcquisitionPanel <AcquisitionPanel
query={airDCPPQuery} query={airDCPPQuery}
comicObjectId={_id} comicObjectId={_id}
comicObject={data.data} comicObject={data.data}
userSettings={userSettings} settings={userSettings}
key={4} key={4}
/> />
), ),
@@ -215,26 +371,37 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, },
{ {
id: 5, id: 5,
icon: <i className="fa-solid fa-droplet"></i>, icon: (
<span className="inline-flex flex-row">
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
</span>
),
name: "Torrent Search", name: "Torrent Search",
content: <>Torrents</>, content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
shouldShow: true, shouldShow: true,
}, },
{ {
id: 6, id: 6,
icon: null, name: "Downloads",
name: !isEmpty(data.data) ? ( icon: (
<span className="download-tab-name">Downloads</span> <>
) : ( {(acquisition?.directconnect?.downloads?.length || 0) +
"Downloads" (acquisition?.torrent?.length || 0)}
), </>
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
), ),
content:
!isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} />
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
),
shouldShow: true, shouldShow: true,
}, },
]; ];
@@ -246,76 +413,75 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// 2. from the CV-scraped version // 2. from the CV-scraped version
return ( return (
<section className="container"> <section className="container mx-auto">
<div className="section"> <div className="section">
{!isNil(data) && !isEmpty(data) && ( {!isNil(data) && !isEmpty(data) && (
<> <>
<h1 className="title">{issueName}</h1> <div>
<div className="columns is-multiline"> <div className="flex flex-row mt-5">
<div className="column is-narrow">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"vertical"} orientation={"cover-only"}
hasDetails={false} hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }} cardContainerStyle={{ maxWidth: "290px", width: "100%" }}
/> />
{/* action dropdown */}
<div className="mt-4 is-size-7"> {/* raw file details */}
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div>
</div>
{/* raw file details */}
<div className="column">
{!isUndefined(rawFileDetails) && {!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && ( !isEmpty((rawFileDetails as any)?.cover) && (
<> <div className="grid">
<RawFileDetails <RawFileDetails
data={{ data={{
rawFileDetails: rawFileDetails, rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata, inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
}} }}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
> >
<i className="fa-solid fa-book-open mr-2"></i> {/* action dropdown */}
Read <div className="mt-1 flex flex-row gap-2 w-full">
</button> <Menu
data={data.data}
{/* <Modal handlers={{ setSlidingPanelContentId, setVisible }}
style={{ content: { marginTop: "2rem" } }} configuration={{
isOpen={modalIsOpen} filteredActionOptions,
onAfterOpen={afterOpenModal} customStyles,
onRequestClose={closeModal} handleActionSelection,
contentLabel="Example Modal" Placeholder,
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}} }}
/> />
)} </div>
</Modal> */} </RawFileDetails>
</>
{/* <Modal
style={{ content: { marginTop: "2rem" } }}
isOpen={modalIsOpen}
onAfterOpen={afterOpenModal}
onRequestClose={closeModal}
contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}}
/>
)}
</Modal> */}
</div>
)} )}
</div> </div>
</div> </div>
<TabControls <TabControls
filteredTabs={filteredTabs} filteredTabs={filteredTabs}
acquisition={acquisition} downloadCount={acquisition?.directconnect?.downloads?.length || 0}
/> />
<SlidingPane <StyledSlidingPanel
isOpen={visible} isOpen={visible}
onRequestClose={() => setVisible(false)} onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"} title={"Comic Vine Search Matches"}
@@ -323,7 +489,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
> >
{slidingPanelContentId !== "" && {slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()} contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane> </StyledSlidingPanel>
</> </>
)} )}
</div> </div>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,106 +1,157 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns";
interface RawFileDetailsProps {
data?: {
rawFileDetails?: {
containedIn?: string;
name?: string;
fileSize?: number;
path?: string;
extension?: string;
mimeType?: string;
cover?: {
filePath?: string;
};
};
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
};
children?: any;
}
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data || {};
if (!rawFileDetails) return null;
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata } = props.data;
return ( return (
<> <>
<div className="comic-detail raw-file-details column is-three-fifths"> <div className="max-w-2xl ml-5">
<dl> {/* Title */}
<dt>Raw File Details</dt> <div className="px-4 sm:px-6 mb-6">
<dd className="is-size-7"> <p className="text-gray-500 dark:text-gray-400">
{rawFileDetails.containedIn + <span className="text-xl font-semibold">{rawFileDetails?.name}</span>
"/" + </p>
rawFileDetails.name + </div>
rawFileDetails.extension}
</dd>
<dd>
<div className="field is-grouped mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Size</span>
<span className="tag is-info is-light">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Extension</span>
<span className="tag is-primary is-light">
{rawFileDetails.extension}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">MIME type</span>
<span className="tag is-primary is-light">
{rawFileDetails.mimeType}
</span>
</div>
</div>
</div>
</dd>
</dl>
</div>
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths"> {/* File Binary Details Section */}
<dl> <div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700">
{/* inferred metadata */} <div className="mb-4">
<dt>Inferred Issue Metadata</dt> <h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
<dd> <i className="icon-[solar--document-bold-duotone] w-5 h-5"></i>
<div className="field is-grouped mt-2"> File Binary Details
<div className="control"> </h3>
<div className="tags has-addons"> </div>
<span className="tag">Name</span> <div className="pl-6">
<span className="tag is-info is-light"> <dl className="space-y-4">
{inferredMetadata.issue.name} <div>
</span> <dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
File Path
</dt>
<dd className="text-sm text-gray-900 dark:text-gray-300 font-mono break-all">
{rawFileDetails?.containedIn}/{rawFileDetails?.name}{rawFileDetails?.extension}
</dd>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
MIME Type
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
{rawFileDetails?.mimeType}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
File Size
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : 'N/A'}
</dd>
</div> </div>
</div> </div>
{!isEmpty(inferredMetadata.issue.number) ? ( </dl>
<div className="control"> </div>
<div className="tags has-addons"> </div>
<span className="tag">Number</span>
<span className="tag is-primary is-light"> {/* Import Details Section */}
{inferredMetadata.issue.number} <div className="mb-8 px-4">
</span> <div className="mb-4">
</div> <h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
<i className="icon-[solar--import-bold-duotone] w-5 h-5"></i>
Import Details
</h3>
</div>
<div className="pl-6">
<dl className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Imported On
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
{created_at ? format(parseISO(created_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
</dd>
</div> </div>
) : null}
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Last Updated
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
{updated_at ? format(parseISO(updated_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
</dd>
</div>
</div>
{inferredMetadata?.issue && (
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
Inferred Metadata
</dt>
<dd className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">{inferredMetadata.issue.name}</span>
{!isEmpty(inferredMetadata.issue.number) && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
#{inferredMetadata.issue.number}
</span>
)}
</dd>
</div>
)}
</dl>
</div>
</div>
{/* Actions Section */}
{props.children && (
<div className="px-4">
<div className="mb-3">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide">
Actions
</h4>
</div> </div>
</dd> <div>{props.children}</div>
</dl> </div>
)}
</div> </div>
</> </>
); );
}; };
export default RawFileDetails; export default RawFileDetails;
RawFileDetails.propTypes = {
data: PropTypes.shape({
rawFileDetails: PropTypes.shape({
containedIn: PropTypes.string,
name: PropTypes.string,
fileSize: PropTypes.number,
path: PropTypes.string,
extension: PropTypes.string,
mimeType: PropTypes.string,
cover: PropTypes.shape({
filePath: PropTypes.string,
}),
}),
inferredMetadata: PropTypes.shape({
issue: PropTypes.shape({
year: PropTypes.string,
name: PropTypes.string,
number: PropTypes.number,
subtitle: PropTypes.string,
}),
}),
}),
};

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,65 +1,81 @@
import React, { ReactElement, useEffect } from "react"; import React, { ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import ZeroState from "./ZeroState"; import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported"; import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList"; import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups"; import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics"; import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList"; import { PullList } from "./PullList";
import { import { useQuery } from "@tanstack/react-query";
fetchVolumeGroups, import axios from "axios";
getComicBooks, import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil } from "lodash";
import Header from "../shared/Header";
export const Dashboard = (): ReactElement => { export const Dashboard = (): ReactElement => {
// useEffect(() => { const { data: recentComics } = useQuery({
// dispatch(fetchVolumeGroups()); queryFn: async () =>
// dispatch( await axios({
// getComicBooks({ url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
// paginationOptions: { method: "POST",
// page: 0, data: {
// limit: 5, paginationOptions: {
// sort: { updatedAt: "-1" }, page: 0,
// }, limit: 5,
// predicate: { "acquisition.source.wanted": false }, sort: { updatedAt: "-1" },
// comicStatus: "recent", },
// }), predicate: {
// ); wanted: { $exists: false },
// dispatch( },
// getComicBooks({ comicStatus: "recent",
// paginationOptions: { },
// page: 0, }),
// limit: 5, queryKey: ["recentComics"],
// sort: { updatedAt: "-1" }, });
// }, // Wanted Comics
// predicate: { "acquisition.source.wanted": true }, const { data: wantedComics } = useQuery({
// comicStatus: "wanted", queryFn: async () =>
// }), await axios({
// ); url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
// dispatch(getLibraryStatistics()); method: "POST",
// }, []); data: {
// paginationOptions: {
// const recentComics = useSelector( page: 0,
// (state: RootState) => state.fileOps.recentComics, limit: 5,
// ); sort: { updatedAt: "-1" },
// const wantedComics = useSelector( },
// (state: RootState) => state.fileOps.wantedComics, predicate: {
// ); wanted: { $exists: true, $ne: null },
// const volumeGroups = useSelector( },
// (state: RootState) => state.fileOps.comicVolumeGroups, },
// ); }),
// queryKey: ["wantedComics"],
// const libraryStatistics = useSelector( });
// (state: RootState) => state.comicInfo.libraryStatistics, const { data: volumeGroups } = useQuery({
// ); queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
}),
queryKey: ["volumeGroups"],
});
const { data: statistics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
method: "GET",
}),
queryKey: ["libraryStatistics"],
});
return ( return (
<div className="container"> <div className="container mx-auto max-w-full">
<section className="section"> <PullList />
<h1 className="title">Dashboard</h1> {recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
</section> {/* Wanted comics */}
<WantedComicsList comics={wantedComics?.data?.docs} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics?.data} />}
{/* Volume groups */}
<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

@@ -1,160 +1,178 @@
import { isNil, map } from "lodash"; import React, { ReactElement, useState, useEffect } from "react";
import React, { createRef, ReactElement, useCallback, useEffect } from "react"; import { map } from "lodash";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import Header from "../shared/Header"; import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions"; import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import { setupCache } from "axios-cache-interceptor";
import { useQuery } from "@tanstack/react-query";
import "keen-slider/keen-slider.min.css";
import { useKeenSlider } from "keen-slider/react";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { Field, Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
type PullListProps = { type PullListProps = {
issues: any; issues: any;
}; };
export const PullList = ({ issues }: PullListProps): ReactElement => { const http = rateLimiter(axios.create(), {
const dispatch = useDispatch(); maxRequests: 1,
useEffect(() => { perMilliseconds: 1000,
dispatch( maxRPS: 1,
getWeeklyPullList({ });
startDate: "2023-9-9", const cachedAxios = setupCache(axios);
pageSize: "15", export const PullList = (): ReactElement => {
currentPage: "1", // datepicker
}), const date = new Date();
); const [inputValue, setInputValue] = useState<string>(
}, []); format(date, "yyyy/M/dd"),
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
); );
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
};
*/
const pullList = useSelector((state: RootState) => state.comicInfo.pullList); // Responsive slides per view
let sliderRef = createRef(); const [slidesPerView, setSlidesPerView] = useState(1);
const settings = {
dots: false,
infinite: false,
speed: 500, // keen slider
slidesToShow: 5, const [sliderRef, instanceRef] = useKeenSlider({
slidesToScroll: 5, loop: true,
initialSlide: 0, mode: "free-snap",
responsive: [ slides: {
{ perView: slidesPerView,
breakpoint: 1024, spacing: 15,
settings: { },
slidesToShow: 3, slideChanged() {
slidesToScroll: 3, console.log("slide changed");
infinite: false, },
});
// Update slider when slidesPerView changes
useEffect(() => {
if (instanceRef.current) {
instanceRef.current.update({
slides: {
perView: slidesPerView,
spacing: 15,
}, },
}, });
{ }
breakpoint: 600, }, [slidesPerView, instanceRef]);
settings: {
slidesToShow: 2, const {
slidesToScroll: 2, data: pullList,
initialSlide: 0, refetch,
}, isSuccess,
}, isLoading,
{ isError,
breakpoint: 480, } = useQuery({
settings: { queryFn: async (): any =>
slidesToShow: 1, await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
slidesToScroll: 1, method: "get",
}, params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
}, }),
], queryKey: ["pullList", inputValue],
}; });
const addToLibrary = (sourceName: string, locgMetadata) =>
importToDB(sourceName, { locg: locgMetadata });
const next = () => { const next = () => {
sliderRef.slickNext(); // sliderRef.slickNext();
}; };
const previous = () => { const previous = () => {
sliderRef.slickPrev(); // sliderRef.slickPrev();
}; };
return ( return (
<> <>
<div className="content"> <div className="content">
<Header headerContent="Discover" <div className="mx-auto">
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks" <Header
iconClassNames="fa-solid fa-binoculars mr-2"/> headerContent="Discover"
<div className="field is-grouped"> subHeaderContent={
{/* select week */} <span className="text-md">
<div className="control"> Pull List aggregated for the week from{" "}
<div className="select is-small"> <span className="underline">
<select> <a href="https://leagueofcomicgeeks.com">
<option>Select Week</option> League Of Comic Geeks
<option>With options</option> </a>
</select> <i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</div> </span>
</div> </span>
{/* See all pull list issues */} }
<div className="control"> iconClassNames="fa-solid fa-binoculars mr-2"
<Link to={"/pull-list/all/"}> link="/pull-list/all/"
<button className="button is-small">View all issues</button> />
</Link> <div className="flex flex-row gap-5 mb-3">
</div> {/* select week */}
<div className="field has-addons"> <div className="flex flex-row gap-4 my-3">
<div className="control"> <Form
<button className="button is-rounded is-small" onClick={previous}> onSubmit={() => {}}
<i className="fa-solid fa-caret-left"></i> render={({ handleSubmit }) => (
</button> <form>
</div> <div className="flex flex-col gap-2">
<div className="control"> {/* week selection for pull list */}
<button className="button is-rounded is-small" onClick={next}> <DatePickerDialog
<i className="fa-solid fa-caret-right"></i> inputValue={inputValue}
</button> setter={setInputValue}
/>
{inputValue && (
<div className="text-sm">
Showing pull list for{" "}
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{inputValue}
</span>
</div>
)}
</div>
</form>
)}
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) && {isSuccess && !isLoading && (
pullList && <div ref={sliderRef} className="keen-slider">
map(pullList, ({ issue }, idx) => { {map(pullList?.data.result, (issue, idx) => {
return ( return (
<div key={idx} className="keen-slider__slide">
<Card <Card
key={idx} orientation={"vertical-2"}
orientation={"vertical"} imageUrl={issue.coverImageUrl}
imageUrl={issue.cover}
hasDetails hasDetails
title={ellipsize(issue.name, 18)} title={ellipsize(issue.issueName, 25)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
> >
<div className="content"> <div className="px-1">
<div className="control"> <span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="tag">{issue.publisher}</span> {issue.publicationDate}
</div> </span>
<div className="mt-2"> <div className="flex flex-row justify-end">
<button <button
className="button is-small is-success is-outlined is-light" className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => addToLibrary("locg", issue)} onClick={() => addToLibrary("locg", issue)}
> >
<i className="fa-solid fa-plus"></i> Want <i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want
</button> </button>
</div> </div>
</div> </div>
</Card> </Card>
); </div>
);
})} })}
</Slider> </div>
)}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</> </>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,8 @@ interface ZeroStateProps {
} }
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => { const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
return ( return (
<article className="message is-info"> <article className="">
<div className="message-body"> <div className="">
<p>{props.header}</p> <p>{props.header}</p>
{props.message} {props.message}
</div> </div>

View File

@@ -1,13 +1,5 @@
import React, { import React, { ReactElement, useEffect, useState } from "react";
ReactElement,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getTransfers } from "../../actions/airdcpp.actions"; import { getTransfers } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
@@ -17,18 +9,18 @@ interface IDownloadsProps {
} }
export const Downloads = (props: IDownloadsProps): ReactElement => { export const Downloads = (props: IDownloadsProps): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext); // const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { const {
airDCPPState: { settings, socket }, airDCPPState: { settings, socket },
} = airDCPPConfiguration; } = airDCPPConfiguration;
const dispatch = useDispatch(); // const dispatch = useDispatch();
const airDCPPTransfers = useSelector( // const airDCPPTransfers = useSelector(
(state: RootState) => state.airdcpp.transfers, // (state: RootState) => state.airdcpp.transfers,
); // );
const issueBundles = useSelector( // const issueBundles = useSelector(
(state: RootState) => state.airdcpp.issue_bundles, // (state: RootState) => state.airdcpp.issue_bundles,
); // );
const [bundles, setBundles] = useState([]); const [bundles, setBundles] = useState([]);
// Make the call to get all transfers from AirDC++ // Make the call to get all transfers from AirDC++
useEffect(() => { useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useCallback, useEffect } from "react"; import React, { ReactElement, useCallback, useEffect, useRef } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns"; import { format } from "date-fns";
import Loader from "react-loader-spinner"; import Loader from "react-loader-spinner";
@@ -27,13 +27,22 @@ interface IProps {
export const Import = (props: IProps): ReactElement => { export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { importJobQueue, socketIOInstance } = useStore( const { importJobQueue, getSocket, setQueryClientRef } = useStore(
useShallow((state) => ({ useShallow((state) => ({
importJobQueue: state.importJobQueue, importJobQueue: state.importJobQueue,
socketIOInstance: state.socketIOInstance, getSocket: state.getSocket,
setQueryClientRef: state.setQueryClientRef,
})), })),
); );
const previousResultCountRef = useRef<number>(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Set the queryClient reference in the store so socket events can use it
useEffect(() => {
setQueryClientRef({ current: queryClient });
}, [queryClient, setQueryClientRef]);
const sessionId = localStorage.getItem("sessionId"); const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({ const { mutate: initiateImport } = useMutation({
mutationFn: async () => mutationFn: async () =>
@@ -44,43 +53,91 @@ export const Import = (props: IProps): ReactElement => {
}), }),
}); });
const { data, isError, isLoading } = useQuery({ const { data, isError, isLoading, refetch } = useQuery({
queryKey: ["allImportJobResults"], queryKey: ["allImportJobResults"],
queryFn: async () => queryFn: async () => {
await axios({ const response = await axios({
method: "GET", method: "GET",
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics", url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
}), params: {
_t: Date.now(), // Cache buster
},
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
},
});
// Track the result count
if (response.data?.length) {
previousResultCountRef.current = response.data.length;
}
return response;
},
refetchOnMount: true,
refetchOnWindowFocus: false,
staleTime: 0, // Always consider data stale
gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
// Poll every 5 seconds when import is running
refetchInterval: importJobQueue.status === "running" || importJobQueue.status === "paused" ? 5000 : false,
}); });
// 1a. Act on each comic issue successfully imported/failed, as indicated // Listen for import queue drained event to refresh the table
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events useEffect(() => {
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => { const socket = getSocket("/");
const { completedJobCount, importResult } = data;
importJobQueue.setJobCount("successful", completedJobCount); const handleQueueDrained = () => {
importJobQueue.setMostRecentImport(importResult.rawFileDetails.name); const initialCount = previousResultCountRef.current;
}); let attempts = 0;
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => { const maxAttempts = 20; // Poll for up to 20 seconds
const { failedJobCount } = data;
importJobQueue.setJobCount("failed", failedJobCount); // Clear any existing polling interval
}); if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
// Poll every second until we see new data or hit max attempts
pollingIntervalRef.current = setInterval(async () => {
attempts++;
const result = await refetch();
const newCount = result.data?.data?.length || 0;
if (newCount > initialCount) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
} else if (attempts >= maxAttempts) {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}
}, 1000);
};
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, [getSocket, queryClient, refetch]);
// 1b. Clear the localStorage sessionId upon receiving the
// LS_IMPORT_QUEUE_DRAINED event
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
localStorage.removeItem("sessionId");
importJobQueue.setStatus("drained");
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
const toggleQueue = (queueAction: string, queueStatus: string) => { const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit( const socket = getSocket("/");
socket.emit(
"call", "call",
"socket.setQueueStatus", "socket.setQueueStatus",
{ {
queueAction, queueAction,
queueStatus, queueStatus,
}, },
(data) => console.log(data), (data: any) => console.log(data),
); );
}; };
/** /**
@@ -94,29 +151,35 @@ export const Import = (props: IProps): ReactElement => {
switch (status) { switch (status) {
case "running": case "running":
return ( return (
<div className="control"> <div>
<button <button
className="button is-warning is-light" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => { onClick={() => {
toggleQueue("pause", "paused"); toggleQueue("pause", "paused");
importJobQueue.setStatus("paused"); importJobQueue.setStatus("paused");
}} }}
> >
<i className="fa-solid fa-pause mr-2"></i> Pause <span className="text-md">Pause</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
case "paused": case "paused":
return ( return (
<div className="control"> <div>
<button <button
className="button is-success is-light" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => { onClick={() => {
toggleQueue("resume", "running"); toggleQueue("resume", "running");
importJobQueue.setStatus("running"); importJobQueue.setStatus("running");
}} }}
> >
<i className="fa-solid fa-play mr-2"></i> Resume <span className="text-md">Resume</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
@@ -129,145 +192,180 @@ export const Import = (props: IProps): ReactElement => {
} }
}; };
return ( return (
<div className="container"> <div>
<section className="section is-small"> <section>
<h1 className="title">Import Comics</h1> <header className="bg-slate-200 dark:bg-slate-500">
<article className="message is-dark"> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="message-body"> <div className="sm:flex sm:items-center sm:justify-between">
<p className="mb-2"> <div className="text-center sm:text-left">
<span className="tag is-medium is-info is-light"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Import Comics Import
</span> </h1>
will add comics identified from the mapped folder into ThreeTwo's
database. <p className="mt-1.5 text-sm text-gray-500 dark:text-white">
</p> Import comics into the ThreeTwo library.
<p> </p>
Metadata from ComicInfo.xml, if present, will also be extracted. </div>
</p> </div>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div> </div>
</article> </header>
<p className="buttons">
<button <div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
className={ <article
importJobQueue.status === "drained" || role="alert"
importJobQueue.status === undefined className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
? "button is-medium"
: "button is-loading is-medium"
}
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
> >
<span className="icon"> <div>
<i className="fas fa-file-import"></i> <p>
</span> Importing will add comics identified from the mapped folder into
<span>Start Import</span> ThreeTwo's database.
</button> </p>
</p> <p>
Metadata from ComicInfo.xml, if present, will also be extracted.
</p>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div>
</article>
{importJobQueue.status !== "drained" && <div className="my-4">
!isUndefined(importJobQueue.status) && ( {importJobQueue.status === "drained" ||
(importJobQueue.status === undefined && (
<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-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Start Import</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
))}
</div>
{/* Activity */}
{(importJobQueue.status === "running" ||
importJobQueue.status === "paused") && (
<> <>
<table className="table"> <span className="flex items-center my-5 max-w-screen-lg">
<thead> <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
<tr> Import Activity
<th>Completed Jobs</th> </span>
<th>Failed Jobs</th> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<th>Queue Controls</th>
<th>Queue Status</th>
</tr>
</thead>
<tbody>
<tr>
<th>
{importJobQueue.successfulJobCount > 0 && (
<div className="box has-background-success-light has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.successfulJobCount}
</span>
</div>
)}
</th>
<td>
{importJobQueue.failedJobCount > 0 && (
<div className="box has-background-danger has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.failedJobCount}
</span>
</div>
)}
</td>
<td>{renderQueueControls(importJobQueue.status)}</td>
<td>
{importJobQueue.status !== undefined ? (
<span className="tag is-warning">
{importJobQueue.status}
</span>
) : null}
</td>
</tr>
</tbody>
</table>
Imported{" "}
<span className="has-text-weight-bold">
{importJobQueue.mostRecentImport}
</span> </span>
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
{/* Successful import counts */}
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dd className="text-3xl text-green-600 md:text-5xl">
{importJobQueue.successfulJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
imported
</dt>
</div>
{/* Failed job counts */}
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
<dd className="text-3xl text-red-600 md:text-5xl">
{importJobQueue.failedJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
failed
</dt>
</div>
<div className="flex flex-col dark:text-slate-200 text-slate-400">
<dd>{renderQueueControls(importJobQueue.status)}</dd>
</div>
</dl>
</div>
<div className="flex">
<span className="mt-2 dark:text-slate-200 text-slate-400">
Imported: <span>{importJobQueue.mostRecentImport}</span>
</span>
</div>
</> </>
)} )}
{/* Past imports */} {/* Past imports */}
{!isLoading && !isEmpty(data?.data) && ( {!isLoading && !isEmpty(data?.data) && (
<> <div className="max-w-screen-lg">
<h3 className="subtitle is-4 mt-5">Past Imports</h3> <span className="flex items-center mt-6">
<table className="table"> <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
<thead> Past Imports
<tr> </span>
<th>Time Started</th> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<th>Session Id</th> </span>
<th>Imported</th>
<th>Failed</th>
</tr>
</thead>
<tbody> <div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
{data?.data.map((jobResult, id) => { <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
return ( <thead className="ltr:text-left rtl:text-right">
<tr key={id}> <tr>
<td> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
{format( Time Started
new Date(jobResult.earliestTimestamp), </th>
"EEEE, hh:mma, do LLLL Y", <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
)} Session Id
</td> </th>
<td> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
<span className="tag is-warning"> Imported
{jobResult.sessionId} </th>
</span> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
</td> Failed
<td> </th>
<span className="tag is-success">
{jobResult.completedJobs}
</span>
</td>
<td>
<span className="tag is-danger">
{jobResult.failedJobs}
</span>
</td>
</tr> </tr>
); </thead>
})}
</tbody> <tbody className="divide-y divide-gray-200">
</table> {data?.data.map((jobResult: any, id: number) => {
</> return (
)} <tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.completedJobs}
</p>
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
<span className="h-5 w-6">
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.failedJobs}
</p>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</section> </section>
</div> </div>
); );

View File

@@ -1,12 +1,18 @@
import React, { useMemo, ReactElement, useState } from "react"; import React, { useMemo, ReactElement, useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { useQuery, keepPreviousData } from "@tanstack/react-query"; import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
/** /**
* Component that tabulates the contents of the user's ThreeTwo Library. * Component that tabulates the contents of the user's ThreeTwo Library.
@@ -19,32 +25,56 @@ export const Library = (): ReactElement => {
// Default page state // Default page state
// offset: 0 // offset: 0
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({
// Method to fetch paginated issues query: {},
const fetchIssues = async (searchQuery, offset, type) => { pagination: {
let pagination = { size: 25,
size: 15,
from: offset, from: offset,
}; },
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/**
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
return await axios({ return await axios({
method: "POST", method: "POST",
url: "http://localhost:3000/api/search/searchIssue", url: "http://localhost:3000/api/search/searchIssue",
data: { data: {
searchQuery, query,
pagination, pagination,
type, type,
}, },
}); });
}; };
const searchIssues = (e) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({ const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset], queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues({}, offset, "all"), queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const searchResults = data?.data; const searchResults = data?.data;
// Programmatically navigate to comic detail // Programmatically navigate to comic detail
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToComicDetail = (row) => { const navigateToComicDetail = (row) => {
@@ -53,45 +83,42 @@ export const Library = (): ReactElement => {
const ComicInfoXML = (value) => { const ComicInfoXML = (value) => {
return value.data ? ( return value.data ? (
<div className="comicvine-metadata mt-3"> <dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
<dl> {/* Series Name */}
<span className="tags has-addons is-size-7"> <span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="tag">Series</span> <span className="pr-1 pt-1">
<span className="tag is-warning is-light"> <i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
{ellipsize(value.data.series[0], 25)} </span>
<span className="text-md text-slate-900 dark:text-slate-900">
{ellipsize(value.data.series[0], 45)}
</span>
</span>
<div className="flex flex-row mt-2 gap-2">
{/* Pages */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Pages: {value.data.pagecount[0]}
</span> </span>
</span> </span>
</dl> {/* Issue number */}
<dl> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<div className="field is-grouped is-grouped-multiline"> <span className="pr-1 pt-1">
<div className="control"> <i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
<span className="tags has-addons is-size-7 mt-2"> </span>
<span className="tag">Pages</span> <span className="text-slate-900 dark:text-slate-900">
<span className="tag is-info is-light has-text-weight-bold"> {!isNil(value.data.number) && (
{value.data.pagecount[0]} <span>{parseInt(value.data.number[0], 10)}</span>
</span> )}
</span> </span>
</div> </span>
</div>
<div className="control"> </dl>
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Issue</span>
{!isNil(value.data.number) && (
<span className="tag has-text-weight-bold is-success is-light">
{parseInt(value.data.number[0], 10)}
</span>
)}
</span>
</div>
</div>
</dl>
</div>
) : null; ) : null;
}; };
const WantedStatus = ({ value }) => {
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
};
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@@ -110,14 +137,10 @@ export const Library = (): ReactElement => {
{ {
header: "ComicInfo.xml", header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo", accessorKey: "_source.sourcedMetadata.comicInfo",
align: "center",
minWidth: 250,
cell: (info) => cell: (info) =>
!isEmpty(info.getValue()) ? ( !isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} /> <ComicInfoXML data={info.getValue()} />
) : ( ) : null,
<span className="tag mt-5">No ComicInfo.xml</span>
),
}, },
], ],
}, },
@@ -125,28 +148,43 @@ export const Library = (): ReactElement => {
header: "Additional Metadata", header: "Additional Metadata",
columns: [ columns: [
{ {
header: "Publisher", header: "Date of Import",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation", accessorKey: "_source.createdAt",
cell: (info) => { cell: (info) => {
return ( return !isNil(info.getValue()) ? (
!isNil(info.getValue()) && ( <span className="inline-flex items-center bg-slate-300 dark:bg-slate-500 text-xs font-medium text-slate-700 dark:text-slate-200 px-3 py-1 rounded-md shadow-sm whitespace-nowrap ml-3 my-3">
<h6 className="is-size-7 has-text-weight-bold"> <i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" />
{info.getValue().publisher.name} {format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")}
</h6> </span>
) ) : null;
);
}, },
}, },
{ {
header: "Something", header: "Downloads",
accessorKey: "_source.acquisition.source.wanted", accessorKey: "_source.acquisition",
cell: (info) => { cell: (info) => (
!isUndefined(info.getValue()) ? ( <div className="flex flex-col gap-2 ml-3 my-3">
<WantedStatus value={info.getValue().toString()} /> {/* DC++ Downloads */}
) : ( {info.getValue().directconnect?.downloads?.length > 0 ? (
"Nothing" <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
); <i className="icon-[solar--folder-path-connect-bold-duotone] w-4 h-4 mr-1 opacity-70" />
}, <span>
DC++: {info.getValue().directconnect.downloads.length}
</span>
</span>
) : null}
{/* Torrent Downloads */}
{info.getValue().torrent.length > 0 ? (
<span className="inline-flex items-center whitespace-nowrap bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
<i className="icon-[solar--magnet-bold-duotone] w-4 h-4 mr-1 opacity-70" />
<span className="whitespace-nowrap">
Torrent: {info.getValue().torrent.length}
</span>
</span>
) : null}
</div>
),
}, },
], ],
}, },
@@ -164,7 +202,17 @@ export const Library = (): ReactElement => {
**/ **/
const nextPage = (pageIndex: number, pageSize: number) => { const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) { if (!isPlaceholderData) {
setOffset(pageSize * pageIndex + 1); queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
} }
}; };
@@ -182,21 +230,43 @@ export const Library = (): ReactElement => {
} else { } else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1); from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
} }
setOffset(from); queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(from);
}; };
// ImportStatus.propTypes = { // ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired, // value: PropTypes.bool.isRequired,
// }; // };
return ( return (
<section className="container"> <div>
<div className="section"> <section>
<div className="header-area"> <header className="bg-slate-200 dark:bg-slate-500">
<h1 className="title">Library</h1> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
</div> <div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{!isUndefined(searchResults?.hits) ? ( {!isUndefined(searchResults?.hits) ? (
<div> <div>
<div className="library"> <div>
<T2Table <T2Table
totalPages={searchResults.hits.total.value} totalPages={searchResults.hits.total.value}
columns={columns} columns={columns}
@@ -206,33 +276,42 @@ export const Library = (): ReactElement => {
nextPage, nextPage,
previousPage, previousPage,
}} }}
/> >
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div> </div>
</div> </div>
) : ( ) : (
<div className="columns"> <div className="mx-auto max-w-screen-xl mt-5">
<div className="column is-two-thirds"> <article
<article className="message is-link"> role="alert"
<div className="message-body"> className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come indices. Try importing a few comics into the library and come
back. back.
</div> </p>
</article> </div>
{!isUndefined(searchResults?.data?.meta?.body) ? ( </article>
<pre> <div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
{JSON.stringify( <pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
searchResults.data.meta.body.error.root_cause, {!isUndefined(searchResults?.data?.meta?.body) ? (
null, <p>
4, {JSON.stringify(
)} searchResults?.data.meta.body.error.root_cause,
</pre> null,
) : null} 4,
)}
</p>
) : null}
</pre>
</div> </div>
</div> </div>
)} )}
</div> </section>
</section> </div>
); );
}; };

View File

@@ -1,60 +1,47 @@
import React, { ReactElement, useCallback } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (): ReactElement => { export const SearchBar = (props): ReactElement => {
const handleSubmit = useCallback((e) => { const { searchHandler } = props;
// dispatch(
// searchIssue(
// {
// query: {
// volumeName: e.search,
// },
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumeName",
// trigger: "libraryPage",
// },
// ),
// );
}, []);
return ( return (
<div className="box"> <Form
<Form onSubmit={searchHandler}
onSubmit={handleSubmit} initialValues={{}}
initialValues={{}} render={({ handleSubmit, form, submitting, pristine, values }) => (
render={({ handleSubmit, form, submitting, pristine, values }) => ( <form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}> <Field name="search">
<div className="field is-grouped"> {({ input, meta }) => {
<div className="control search is-expanded"> return (
<Field name="search"> <div className="flex flex-row w-full">
{({ input, meta }) => { <div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
return ( <div className="w-10 text-gray-400">
<input <i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
{...input} </div>
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name" <input
/> {...input}
); className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
}} type="text"
</Field> id="search"
</div> placeholder="Type an issue/volume name"
<div className="control"> />
<button className="button is-medium" type="submit"> </div>
Search
</button> <button
</div> className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
</div> type="submit"
</form> >
)} Search
/> </button>
</div> </div>
);
}}
</Field>
</form>
)}
/>
); );
}; };

View File

@@ -1,15 +1,21 @@
import React, { useCallback, ReactElement } from "react"; import React, { ReactElement, useState } from "react";
import { isNil, isEmpty } from "lodash"; import { isNil, isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings"; import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { importToDB } from "../../actions/fileops.actions"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { useSelector, useDispatch } from "react-redux";
import { comicinfoAPICall } from "../../actions/comicinfo.actions";
import { search } from "../../services/api/SearchApi";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util";
import PopoverButton from "../shared/PopoverButton";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMutation } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {} interface ISearchProps {}
@@ -17,164 +23,465 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const dispatch = useDispatch(); const [comicVineMetadata, setComicVineMetadata] = useState({});
const getCVSearchResults = useCallback( const [selectedResource, setSelectedResource] = useState("volume");
(searchQuery) => { const { t } = useTranslation();
dispatch( const handleResourceChange = (value) => {
comicinfoAPICall({ setSelectedResource(value);
callURIAction: "search", };
callMethod: "GET",
callParams: { const {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69", mutate,
query: searchQuery.search, data: comicVineSearchResults,
format: "json", isPending,
limit: "10", isSuccess,
offset: "0", } = useMutation({
field_list: mutationFn: async (data: { search: string; resource: string }) => {
"id,name,deck,api_detail_url,image,description,volume,cover_date", const { search, resource } = data;
resources: "issue", return await axios({
}, url: `${COMICVINE_SERVICE_URI}/search`,
}), method: "GET",
); params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: resource,
},
});
}, },
[dispatch], });
);
const addToLibrary = useCallback( // add to library
(sourceName: string, comicData) => const { data: additionResult, mutate: addToWantedList } = useMutation({
dispatch(importToDB(sourceName, { comicvine: comicData })), mutationFn: async ({
[], source,
); comicObject,
markEntireVolumeWanted,
resourceType,
}) => {
let volumeInformation = {};
let issues = [];
switch (resourceType) {
case "issue":
const { id, api_detail_url, image, cover_date, issue_number } =
comicObject;
// Add issue metadata
issues.push({
id,
url: api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
console.log(issues);
// Get volume metadata from CV
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
method: "POST",
data: {
volumeURI: comicObject.volume.api_detail_url,
fieldList:
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
},
});
// set volume metadata key
volumeInformation = response.data?.results;
break;
case "volume":
const {
id: volumeId,
api_detail_url: apiUrl,
image: volumeImage,
name,
publisher,
} = comicObject;
volumeInformation = {
id: volumeId,
url: apiUrl,
image: volumeImage,
name,
publisher,
};
break;
default:
console.log("Invalid resource type.");
break;
}
// Add to wanted list
return await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
importStatus: {
isImported: false, // wanted, but not acquired yet.
tagged: false,
matchedResult: {
score: "0",
},
},
wanted: {
source,
markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
},
},
});
},
});
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => { const createDescriptionMarkup = (html) => {
return { __html: html }; return { __html: html };
}; };
return ( const onSubmit = async (values) => {
<> const formData = { ...values, resource: selectedResource };
<section className="container"> try {
<div className="section search"> mutate(formData);
<h1 className="title">Search</h1> } catch (error) {
// Handle error
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form <Form
onSubmit={getCVSearchResults} onSubmit={onSubmit}
initialValues={{ initialValues={{
...formData, ...formData,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit} className="form columns search"> <form onSubmit={handleSubmit}>
<div className="column is-three-quarters search"> <div className="flex flex-row w-full">
<Field name="search"> <div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
{({ input, meta }) => { <div className="w-10 text-gray-400">
return ( <i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
<input </div>
{...input}
className="input main-search-bar is-large" <Field name="search">
placeholder="Type an issue/volume name" {({ input, meta }) => {
/> return (
); <input
}} {...input}
</Field> className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
</div> placeholder="Type an issue/volume name"
<div className="column"> />
<button type="submit" className="button is-medium"> );
}}
</Field>
</div>
<button
className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
Search Search
</button> </button>
</div> </div>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form> </form>
)} )}
/> />
{!isNil(comicVineSearchResults.results) && </div>
!isEmpty(comicVineSearchResults.results) ? ( {isPending && (
<div className="columns is-multiline"> <div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{comicVineSearchResults.results.map((result) => { Loading results...
return ( </div>
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto w-full sm:w-[90vw] md:w-[80vw] lg:w-[70vw] max-w-6xl px-4 py-6">
{comicVineSearchResults.data.results.map((result) => {
return result.resource_type === "issue" ? (
<div
key={result.id}
className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
>
{/* IMAGE */}
<div className="flex-shrink-0">
<Card
orientation="cover-only"
imageUrl={result.image.small_url}
hasDetails={false}
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
/>
</div>
{/* RIGHT-SIDE CONTENT */}
<div className="flex-1 min-w-0">
{/* TITLE */}
<div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
{result.volume?.name || <span>No Name</span>}
</div>
{/* SUBMETA */}
<div className="flex flex-wrap gap-2 mt-2">
{/* Cover Date Token */}
{result.cover_date && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{dayjs(result.cover_date).format("MMM YYYY")}
</span>
</span>
)}
{/* ID Token */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-xs text-slate-500 dark:text-slate-900">
{result.id}
</span>
</span>
</div>
{/* LINK */}
<a
href={result.api_detail_url}
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
>
{result.api_detail_url}
</a>
{/* DESCRIPTION */}
{result.description && (
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
{ellipsize(
convert(result.description ?? "", {
baseElements: { selectors: ["p", "div"] },
}),
300,
)}
</p>
)}
{/* CTA BUTTON */}
{result.volume.name && (
<div className="absolute bottom-4 right-4">
<PopoverButton
content={`This will add ${result?.volume?.name} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
)}
</div>
</div>
) : (
result.resource_type === "volume" && (
<div <div
key={result.id} key={result.id}
className="comicvine-metadata column is-10 mb-3" className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
> >
<div className="columns"> {/* LEFT COLUMN: COVER */}
<div className="column is-one-quarter"> <Card
<Card orientation="cover-only"
key={result.id} imageUrl={result.image.small_url}
orientation={"vertical"} hasDetails={false}
imageUrl={result.image.small_url} cardContainerStyle={{
title={result.name} width: "120px",
hasDetails={false} maxWidth: "150px",
></Card> }}
/>
{/* RIGHT COLUMN */}
<div className="flex-1 min-w-0">
{/* TITLE */}
<div className="text-lg font-bold text-gray-900 dark:text-white">
{result.name || <span>No Name</span>}
{result.start_year && <> ({result.start_year})</>}
</div> </div>
<div className="column">
<div className="is-size-3"> {/* TOKENS */}
{!isEmpty(result.name) ? ( <div className="flex flex-wrap gap-2 mt-2">
result.name {/* ISSUE COUNT */}
) : ( {result.count_of_issues && (
<span className="is-size-3">No Name</span> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-4 h-4" />
</span>
<span>
{t("issueWithCount", {
count: result.count_of_issues,
})}
</span>
</span>
)}
{/* FORMAT DETECTED */}
{result.description &&
!isEmpty(detectIssueTypes(result.description)) && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-4 h-4" />
</span>
<span>
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
)} )}
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
<div className="control"> {/* ID */}
<div className="tags has-addons"> <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="tag is-warning"> <span className="pr-1 pt-1">
{result.id} <i className="icon-[solar--hashtag-bold-duotone] w-4 h-4" />
</span> </span>
</div> <span>{result.id}</span>
</div> </span>
</div> </div>
<a href={result.api_detail_url}> {/* LINK */}
{result.api_detail_url} <a
</a> href={result.api_detail_url}
<p> className="text-sm text-blue-500 underline mt-2 break-all"
>
{result.api_detail_url}
</a>
{/* DESCRIPTION */}
{result.description && (
<p className="text-sm mt-2 text-slate-700 dark:text-slate-200 break-words line-clamp-3">
{ellipsize( {ellipsize(
convert(result.description, { convert(result.description, {
baseElements: { baseElements: { selectors: ["p", "div"] },
selectors: ["p"],
},
}), }),
320, 320,
)} )}
</p> </p>
<button )}
className="button is-success is-light is-outlined mt-2" {result.name ? (
onClick={() => addToLibrary("comicvine", result)} <div className="mt-4 justify-self-end">
> <PopoverButton
<i className="fa-solid fa-plus mr-2"></i> Want content={`This will add ${result.count_of_issues} issues your wanted list.`}
</button> clickHandler={() =>
</div> addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
) : null}
</div> </div>
</div> </div>
); )
})} );
</div> })}
) : ( </div>
<article className="message is-dark is-half"> ) : (
<div className="message-body"> <div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<p className="mb-2"> <article
<span className="tag is-medium is-info is-light"> role="alert"
Search the ComicVine database className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
</span> >
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
Search and add issues, series and trade paperbacks to your Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or library. Then, download them using the configured AirDC++ or
torrent clients. torrent clients.
</p> </p>
</div> </div>
</article> </article>
)} </div>
</div> )}
</section> </section>
</> </div>
); );
}; };

View File

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

View File

@@ -3,29 +3,36 @@ import React, { ReactElement } from "react";
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => { export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
const { settings } = settingsObject; const { settings } = settingsObject;
return ( return (
<div className="mt-4 is-clearfix"> <div>
<div className="card"> <span className="flex items-center mt-10 mb-4">
<div className="card-content"> <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
<span className="tag is-pulled-right is-primary">Connected</span> AirDC++ Client Information
<div className="content is-size-7"> </span>
<dl> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<dt>{settings._id}</dt> </span>
<dt>Client version: {settings.system_info.client_version}</dt> <div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dt>Hostname: {settings.system_info.hostname}</dt> <span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700">
<dt>Platform: {settings.system_info.platform}</dt> <span className="h-5 w-6">
<i className="icon-[solar--plug-circle-bold] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">Connected</p>
</span>
<p className="font-hasklig text-sm text-slate-700 dark:text-slate-700">
<dl>
<dt>{settings._id}</dt>
<dt>Client version: {settings.system_info.client_version}</dt>
<dt>Hostname: {settings.system_info.hostname}</dt>
<dt>Platform: {settings.system_info.platform}</dt>
<dt>Username: {settings.user.username}</dt> <dt>Username: {settings.user.username}</dt>
<dt>Active Sessions: {settings.user.active_sessions}</dt> <dt>Active Sessions: {settings.user.active_sessions}</dt>
<dt> <dt>
Permissions:{" "} Permissions:{" "}
<pre> {JSON.stringify(settings.user.permissions, undefined, 2)}
{JSON.stringify(settings.user.permissions, undefined, 2)} </dt>
</pre> </dl>
</dt> </p>
</dl>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,77 +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, useQueryClient } 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
: {};
console.log(airDCPPClientConfiguration);
return ( return (
<> <>
<ConnectionForm <ConnectionForm
@@ -80,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()}
@@ -94,7 +81,7 @@ export const AirDCPPSettingsForm = (): ReactElement => {
Delete Delete
</button> </button>
</p> </p>
) : null} )}
</> </>
); );
}; };

View File

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

View File

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

@@ -1,9 +1,10 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm"; import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
export const QbittorrentConnectionForm = (): ReactElement => { export const QbittorrentConnectionForm = (): ReactElement => {
const queryClient = new QueryClient();
// fetch settings // fetch settings
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ["settings"], queryKey: ["settings"],
@@ -13,18 +14,9 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "GET", method: "GET",
}), }),
}); });
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"],
@@ -33,9 +25,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
url: "http://localhost:3060/api/qbittorrent/getClientInfo", url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET", method: "GET",
}), }),
enabled: !!connectionDetails,
}); });
console.log(qbittorrentClientInfo?.data);
// Update action using a mutation // Update action using a mutation
const { mutate } = useMutation({ const { mutate } = useMutation({
mutationFn: async (values) => mutationFn: async (values) =>
@@ -44,6 +34,11 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "POST", method: "POST",
data: { settingsPayload: values, settingsKey: "bittorrent" }, data: { settingsPayload: values, settingsKey: "bittorrent" },
}), }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["settings", "qbittorrentClientInfo"],
});
},
}); });
if (isError) if (isError)
@@ -61,9 +56,24 @@ export const QbittorrentConnectionForm = (): ReactElement => {
submitHandler={mutate} submitHandler={mutate}
/> />
<pre className="mt-5"> <span className="flex items-center mt-10 mb-4">
{JSON.stringify(qbittorrentClientInfo?.data, null, 4)} <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
</pre> qBittorrent Client Information
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--plug-circle-bold] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">Connected</p>
</span>
<pre className="font-hasklig text-sm text-slate-700 dark:text-slate-700">
{JSON.stringify(qbittorrentClientInfo?.data, null, 4)}
</pre>
</div>
</> </>
); );
} }

View File

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

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,124 +4,87 @@ import Card from "../shared/Carda";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { isUndefined } from "lodash"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Volumes = (props): ReactElement => { export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes); // const volumes = useSelector((state: RootState) => state.fileOps.volumes);
useEffect(() => { const {
// dispatch( data: volumes,
// searchIssue( isSuccess,
// { isError,
// query: {}, isLoading,
// }, } = useQuery({
// { queryFn: async () =>
// pagination: { await axios({
// size: 25, url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
// from: 0, method: "POST",
// }, data: {
// type: "volumes", query: {},
// trigger: "volumesPage", pagination: {
// }, size: 25,
// ), from: 0,
// ); },
}, []); type: "volumes",
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
console.log(volumes);
const columnData = useMemo( const columnData = useMemo(
() => [ (): any => [
{ {
header: "Volume Details", header: "Volume Details",
id: "volumeDetails", id: "volumeDetails",
minWidth: 450, minWidth: 450,
accessorKey: "_source", accessorFn: (row) => row,
cell: (row) => { cell: (row): any => {
const foo = row.getValue(); const comicObject = row.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
console.log("jaggu", row.getValue());
return ( return (
<div className="columns"> <div className="flex flex-row gap-3 mt-5">
<div className="column"> <Link to={`/volume/details/${comicObject._id}`}>
<div className="comic-detail issue-metadata"> <Card
<dl> imageUrl={
<dd> sourcedMetadata.comicvine.volumeInformation.image.small_url
<div className="columns mt-2"> }
<div className=""> orientation={"cover-only"}
<Card hasDetails={false}
imageUrl={ />
foo.sourcedMetadata.comicvine.volumeInformation </Link>
.image.thumb_url <div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3">
} <div className="text-xl mb-1 w-fit">
orientation={"vertical"} {sourcedMetadata.comicvine.volumeInformation.name}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div> </div>
<p>
{ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
180,
)}
</p>
</div> </div>
</div> </div>
); );
}, },
}, },
{ {
header: "Download Status", header: "Other Details",
columns: [ columns: [
{ {
header: "Files", header: "Downloads",
accessorKey: "_source.acquisition.directconnect", accessorKey: "_source.acquisition.directconnect",
align: "right", align: "right",
cell: (props) => { cell: (props) => {
@@ -142,12 +105,34 @@ export const Volumes = (props): ReactElement => {
}, },
}, },
{ {
header: "Type", header: "Publisher",
id: "Air", accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
}, },
{ {
header: "Type", header: "Issue Count",
id: "dcc", accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
const row = props.getValue();
return (
<div className="mt-5">
{/* issue count */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-6 h-6"></i>
</span>
<span className="text-lg text-slate-500 dark:text-slate-900">
{row}
</span>
</span>
</div>
);
},
}, },
], ],
}, },
@@ -155,28 +140,45 @@ export const Volumes = (props): ReactElement => {
[], [],
); );
return ( return (
<section className="container"> <div>
<div className="section"> <section className="">
<div className="header-area"> <header className="bg-slate-200 dark:bg-slate-500">
<h1 className="title">Volumes</h1> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
</div> <div className="sm:flex sm:items-center sm:justify-between">
{!isUndefined(volumes.hits) && ( <div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Volumes
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your collection of volumes.
</p>
</div>
</div>
</div>
</header>
{isSuccess ? (
<div> <div>
<div className="library"> <div className="library">
<T2Table <T2Table
sourceData={volumes?.hits} sourceData={volumes?.data.hits.hits}
totalPages={volumes.hits.length} totalPages={volumes?.data.hits.hits.length}
paginationHandlers={{ paginationHandlers={{
nextPage: () => {}, nextPage: () => {},
previousPage: () => {}, previousPage: () => {},
}} }}
rowClickHandler={() => {}}
columns={columnData} columns={columnData}
/> />
</div> </div>
</div> </div>
)} ) : null}
</div> {isError ? (
</section> <div>An error was encountered while retrieving volumes</div>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
); );
}; };

View File

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

View File

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

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

@@ -4,15 +4,15 @@ import { isEmpty, isNil } from "lodash";
interface ICardProps { interface ICardProps {
orientation: string; orientation: string;
imageUrl: string; imageUrl?: string;
hasDetails: boolean; hasDetails?: boolean;
title?: PropTypes.ReactElementLike | null; title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike; children?: PropTypes.ReactNodeLike;
borderColorClass?: string; borderColorClass?: string;
backgroundColor?: string; backgroundColor?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: PropTypes.object; cardContainerStyle?: any;
imageStyle?: PropTypes.object; imageStyle?: any;
} }
const renderCard = (props: ICardProps): ReactElement => { const renderCard = (props: ICardProps): ReactElement => {
@@ -80,6 +80,104 @@ const renderCard = (props: ICardProps): ReactElement => {
</div> </div>
</div> </div>
); );
case "vertical-2":
return (
<div className="block rounded-md max-w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover w-full"
/>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);
case "horizontal-small":
return (
<>
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} className="object-cover h-20 w-20" />
</div>
{/* details */}
<div className="w-fit h-fit pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
</div>
</div>
</>
);
case "horizontal-medium":
return (
<>
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} />
</div>
{/* details */}
<div className="pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
{props.hasDetails && <>{props.children}</>}
</div>
</div>
</>
);
case "cover-only":
const containerStyle = {
width: props.cardContainerStyle?.width || "100%",
height: props.cardContainerStyle?.height || "auto",
maxWidth: props.cardContainerStyle?.maxWidth || "none",
...props.cardContainerStyle,
};
const imageStyle = {
width: "100%",
height: "100%",
objectFit: "cover",
...props.imageStyle,
};
return (
<div
className={`rounded-2xl overflow-hidden shadow-md bg-white dark:bg-slate-800 ${
props.cardContainerStyle?.height ? "" : "aspect-[2/3]"
}`}
style={containerStyle}
>
<img src={props.imageUrl} alt="Comic cover" style={imageStyle} />
</div>
);
case "card-with-info-panel":
return (
<>
<div className="flex flex-row">
{/* thumbnail */}
<div className="rounded-md overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
{/* myata-dyata */}
</div>
</>
);
default: default:
return <></>; return <></>;
} }

View File

@@ -14,91 +14,141 @@ export const ConnectionForm = ({
onSubmit={submitHandler} onSubmit={submitHandler}
initialValues={initialData} initialValues={initialData}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className="mt-10">
<h2>{formHeading}</h2> <h2 className="text-xl">{formHeading}</h2>
<label className="label">Hostname</label> <article
<div className="field has-addons"> role="alert"
<p className="control"> className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
<span className="select"> >
<Field name="protocol" component="select"> <div>
<option>Protocol</option> <p>Configure your AirDC++ client connection here.</p>
<option value="http">http://</option> <p>
<option value="https">https://</option> Note that you need an instance of AirDC++ already running to
</Field> use this form to connect to it.
</span> </p>
</p> <p>
<div className="control is-expanded"> See{" "}
<Field name="hostname" validate={hostNameValidator}> <a
{({ input, meta }) => ( className="underline"
href="http://airdcpp.net/docs/installation/installation.html"
>
here
</a>{" "}
for AirDC++ installation instructions for various platforms.
</p>
</div>
</article>
<span className="flex items-center mt-6">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Configure Connection
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="flex flex-row mt-4">
<div className="relative">
{/* protocol */}
<label className="block py-1">Protocol</label>
<Field
name="protocol"
component="select"
className="appearance-none dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
>
<option>Protocol</option>
<option value="http">http://</option>
<option value="https">https://</option>
</Field>
<div className="absolute h-7 w-7 right-0 px-1 top-11 pointer-events-none">
<i className="icon-[solar--alt-arrow-down-bold]" />
</div>
</div>
{/* hostname */}
<Field name="hostname" validate={hostNameValidator}>
{({ input, meta }) => (
<div className="flex flex-col">
<label className="block px-2 py-1">Hostname</label>
<input
{...input}
type="text"
placeholder="Hostname"
className="ml-2 dark:bg-slate-400 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
/>
<div> <div>
<input
{...input}
type="text"
placeholder="hostname"
className="input"
/>
{meta.error && meta.touched && ( {meta.error && meta.touched && (
<span className="is-size-7 has-text-danger"> <span className="text-sm text-red-400 px-2">
{meta.error} {meta.error}
</span> </span>
)} )}
</div> </div>
)} </div>
</Field> )}
</div> </Field>
<p className="control">
{/* port */}
<div className="flex flex-col">
<label className="block px-2 py-1">Port</label>
<Field <Field
name="port" name="port"
component="input" component="input"
className="input" className="ml-2 dark:bg-slate-400 bg-slate-100 px-2 block h-10 rounded-md sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="port" placeholder="Port"
/> />
</p> </div>
</div> </div>
<div className="field">
<label className="label">Credentials</label> <div className="flex flex-row mt-5">
<div className="field-body"> <div>
<div className="field"> <label className="block py-1">Username</label>
<p className="control is-expanded has-icons-left"> <div className="relative">
<Field <Field
name="username" name="username"
component="input" component="input"
className="input" className="h-10 dark:bg-slate-500 bg-slate-200 rounded-md text-gray-700 dark:text-slate-200 py-1 px-10 mr-5 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Username" placeholder="Username"
/> />
<span className="icon is-small is-left"> <span className="absolute h-6 w-6 left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none">
<i className="fa-solid fa-user-ninja"></i> <i className="icon-[solar--user-bold-duotone] h-6 w-6 dark:text-slate-200" />
</span> </span>
</p>
</div> </div>
<div className="field"> </div>
<p className="control is-expanded has-icons-left has-icons-right"> <div>
<div>
<label className="block py-1">Password</label>
<div className="relative">
<Field <Field
name="password" name="password"
component="input" component="input"
type="password" type="password"
className="input" className="h-10 dark:bg-slate-500 bg-slate-200 rounded-md text-gray-700 dark:text-slate-200 py-1 px-10 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Password" placeholder="Password"
/> />
<span className="icon is-small is-left"> <span className="absolute left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none h-6 w-6">
<i className="fa-solid fa-lock"></i> <i className="icon-[solar--lock-password-bold-duotone] h-6 w-6 dark:text-slate-200" />
</span> </span>
</p> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="field is-grouped"> <div className="flex flex-row gap-2">
<p className="control"> <button
<button type="submit" className="button is-primary"> className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
<span className="text-md">
{!isEmpty(initialData) ? "Update" : "Save"} {!isEmpty(initialData) ? "Update" : "Save"}
</button> </span>
</p> <span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--diskette-bold-duotone]"></i>
</span>
</button>
<p className="control"> <button
<button type="submit" className="button is-danger"> type="submit"
{!isEmpty(initialData) && "Delete"} className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
</button> >
</p> {!isEmpty(initialData) && "Delete"}
</button>
</div> </div>
</form> </form>
)} )}

View File

@@ -0,0 +1,98 @@
import React, { useRef, useState } from "react";
import { format } from "date-fns";
import FocusTrap from "focus-trap-react";
import { ClassNames, DayPicker } from "react-day-picker";
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
import styles from "react-day-picker/dist/style.module.css";
export const DatePickerDialog = (props) => {
const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const classNames: ClassNames = {
...styles,
head: "custom-head",
};
const buttonRef = useRef<HTMLButtonElement>(null);
const { x, y, reference, floating, strategy, refs, update } = useFloating({
placement: "bottom-end",
middleware: [offset(10), flip()],
strategy: "absolute",
});
const closePopper = () => {
setIsPopperOpen(false);
buttonRef.current?.focus();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
if (refs.reference.current && refs.floating.current) {
autoUpdate(refs.reference.current, refs.floating.current, update);
}
};
const handleDaySelect = (date) => {
setSelected(date);
if (date) {
setter(format(date, "yyyy/MM/dd"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={reference}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
onClick={handleButtonClick}
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
ref={floating}
style={{
position: strategy,
zIndex: "999",
borderRadius: "10px",
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
}}
className="bg-slate-400 dark:bg-slate-500"
role="dialog"
aria-label="DayPicker calendar"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
classNames={classNames}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,129 @@
import React, { ReactElement, useState } from "react";
import { Link } from "react-router-dom";
import { useDarkMode } from "../../hooks/useDarkMode";
export const Navbar2 = (): ReactElement => {
const [colorTheme, setTheme] = useDarkMode();
const [darkMode, setDarkMode] = useState(false);
const toggleDarkMode = (checked) => {
setTheme(colorTheme);
setDarkMode(!darkMode);
};
return (
<header className="bg-white dark:bg-gray-900 gap-8 px-5 py-5 h-18 border-b-2 border-gray-300 dark:border-slate-200">
{/* Logo */}
<div className="mx-auto flex">
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
{/* Main Navigation */}
<div className="flex flex-1 items-center justify-end md:justify-between">
<nav
aria-label="ThreeTwo Main Navigation"
className="hidden md:block"
>
<ul className="flex items-center gap-6 text-md">
<li>
<Link
to="/dashboard"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Dashboard
</Link>
</li>
<li>
<Link
to="/import"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Import
</Link>
</li>
<li>
<Link
to="/library"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Library
</Link>
</li>
<li>
<Link
to="/volumes"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Volumes
</Link>
</li>
<li>
<a
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Downloads
</a>
</li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</ul>
</nav>
{/* Right-most Nav */}
<div className="flex items-center gap-4">
<ul className="flex items-center gap-6 text-md">
{/* Settings Icon and text */}
<li>
<Link
to="/settings"
className="flex items-center space-x-1 text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
<span className="w-5 h-5">
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
</span>
<span>Settings</span>
</Link>
</li>
<li>
{/* Light/Dark Mode toggle */}
<div className="flex items-center space-x-2">
<span className="text-gray-600 dark:text-white">Dark</span>
<label
htmlFor="toggle"
className="relative inline-flex items-center"
>
<input
type="checkbox"
id="toggle"
className="sr-only"
checked={darkMode}
onChange={toggleDarkMode}
/>
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
<span
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
darkMode ? "translate-x-4" : ""
}`}
></span>
</label>
<span className="text-gray-600 dark:text-white">Light</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</header>
);
};

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 text-sm space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-1.5 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={clickHandler}
>
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
Mark as Wanted
</button>
{isVisible && (
<div
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
style={floatingStyles}
className="text-xs bg-slate-400 p-1.5 rounded-md"
role="tooltip"
>
{content}
</div>
)}
</div>
);
};
export default PopoverButton;

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
options.apiPath options.apiPath
); );
}; };
console.log(import.meta);
export const CORS_PROXY_SERVER_URI = hostURIBuilder({ export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http", protocol: "http",
@@ -91,3 +90,24 @@ export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
port: "3060", port: "3060",
apiPath: `/api/qbittorrent`, apiPath: `/api/qbittorrent`,
}); });
export const PROWLARR_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/prowlarr`,
});
export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/torrentjobs`,
});
export const AIRDCPP_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/airdcpp`,
});

View File

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

View File

@@ -9,8 +9,8 @@
"displayName": "Dashboard" "displayName": "Dashboard"
}, },
{ {
"id": "gen-gls", "id": "gen-docker-vars",
"displayName": "Global Search" "displayName": "Docker ENV vars"
} }
] ]
}, },
@@ -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

@@ -0,0 +1,17 @@
import React, { useEffect, useState } from "react";
export const useDarkMode = () => {
const [theme, setTheme] = useState(localStorage.theme);
const colorTheme = theme === "dark" ? "light" : "dark";
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove(colorTheme);
root.classList.add(theme);
// save theme to local storage
localStorage.setItem("theme", theme);
}, [theme, colorTheme]);
return [colorTheme, setTheme];
};

View File

@@ -7,12 +7,16 @@ import Settings from "./components/Settings/Settings";
import { ErrorPage } from "./components/shared/ErrorPage"; import { ErrorPage } from "./components/shared/ErrorPage";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
const root = createRoot(rootEl); const root = createRoot(rootEl);
import i18n from "./shared/utils/i18n.util";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import"; import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard"; import Dashboard from "./components/Dashboard/Dashboard";
import Search from "./components/Search/Search";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer"; import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer"; import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
import Volumes from "./components/Volumes/Volumes";
import VolumeDetails from "./components/VolumeDetail/VolumeDetail";
import WantedComics from "./components/WantedComics/WantedComics";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -22,6 +26,7 @@ const router = createBrowserRouter([
element: <App />, element: <App />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ path: "/", element: <Dashboard /> },
{ path: "dashboard", element: <Dashboard /> }, { path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> }, { path: "settings", element: <Settings /> },
{ {
@@ -33,6 +38,10 @@ const router = createBrowserRouter([
element: <ComicDetailContainer />, element: <ComicDetailContainer />,
}, },
{ path: "import", element: <Import path={"./comics"} /> }, { path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> },
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
{ path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> },
], ],
}, },
]); ]);
@@ -40,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.issueName = data.comicvine?.name;
coverFile.comicvine.url = data.comicvine.image.small_url; coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
coverFile.comicvine.issueName = data.comicvine.name;
coverFile.comicvine.publisher = data.comicvine.volumeInformation.publisher;
} }
if (!isEmpty(data.rawFileDetails.cover)) { // rawFileDetails
if (!isEmpty(data.rawFileDetails)) {
const encodedFilePath = encodeURI( const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`, `${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
); );
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath); coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
coverFile.rawFile.issueName = data.rawFileDetails.name; coverFile.rawFile.issueName = data.rawFileDetails.name;
} }
// wanted
if (!isUndefined(data.locg)) { if (!isUndefined(data.locg)) {
coverFile.locg.url = data.locg.cover; coverFile.locg.url = data.locg.cover;
coverFile.locg.issueName = data.locg.name; coverFile.locg.issueName = data.locg.name;
@@ -69,25 +78,30 @@ export const determineCoverFile = (data) => {
export const determineExternalMetadata = ( export const determineExternalMetadata = (
metadataSource: string, metadataSource: string,
source: any source: any,
) => { ): any => {
switch (metadataSource) { if (!isNil(source)) {
case "comicvine": switch (metadataSource) {
return { case "comicvine":
coverURL: source.comicvine.image.small_url, return {
issue: source.comicvine.name, coverURL:
icon: "cvlogo.svg", source.comicvine?.image.small_url ||
}; source.comicvine.volumeInformation?.image.small_url,
case "locg": issue: source.comicvine.name,
return { icon: "cvlogo.svg",
coverURL: source.locg.cover, };
issue: source.locg.name, case "locg":
icon: "locglogo.svg", return {
}; coverURL: source.locg.cover,
case undefined: issue: source.locg.name,
return {}; icon: "locglogo.svg",
};
case undefined:
return {};
default: default:
break; break;
}
} }
return null;
}; };

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

@@ -1,219 +1,177 @@
import { create } from "zustand"; import { create } from "zustand";
import { isEmpty, isNil, isUndefined } from "lodash"; import io, { Socket } 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 { isNil } from "lodash";
import AirDCPPSocket from "../services/DcppSearchService"; import { toast } from "react-toastify";
import axios from "axios"; import "react-toastify/dist/ReactToastify.min.css";
/* Broadly, this file sets up: // Type for global state
* 1. The zustand-based global client state interface StoreState {
* 2. socket.io client socketInstances: Record<string, Socket>;
* 3. AirDC++ websocket connection getSocket: (namespace?: string) => Socket;
*/ disconnectSocket: (namespace: string) => void;
export const useStore = create((set, get) => ({ queryClientRef: { current: any } | null;
// AirDC++ state setQueryClientRef: (ref: any) => void;
airDCPPSocketInstance: {},
airDCPPSocketConnected: false, comicvine: {
airDCPPDisconnectionInfo: {}, scrapingStatus: string;
airDCPPClientConfiguration: {}, };
airDCPPSessionInformation: {},
setAirDCPPSocketConnectionStatus: () => importJobQueue: {
set((value) => ({ successfulJobCount: number;
airDCPPSocketConnected: value, failedJobCount: number;
})), status: string | undefined;
airDCPPDownloadTick: {}, mostRecentImport: string | null;
// Socket.io state
socketIOInstance: {}, setStatus: (status: string) => void;
setJobCount: (jobType: string, count: number) => void;
setMostRecentImport: (fileName: string) => void;
};
}
export const useStore = create<StoreState>((set, get) => ({
socketInstances: {},
queryClientRef: null,
setQueryClientRef: (ref: any) => set({ queryClientRef: ref }),
getSocket: (namespace = "/") => {
const fullNamespace = namespace === "/" ? "" : namespace;
const existing = get().socketInstances[namespace];
if (existing && existing.connected) return existing;
const sessionId = localStorage.getItem("sessionId");
const socket = io(`${SOCKET_BASE_URI}${fullNamespace}`, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
socket.on("connect", () => {
console.log(`✅ Connected to ${namespace}:`, socket.id);
});
if (sessionId) {
socket.emit("call", "socket.resumeSession", { sessionId, namespace });
} else {
socket.on("sessionInitialized", (id) => {
localStorage.setItem("sessionId", id);
});
}
socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
const { completedJobCount, failedJobCount, queueStatus } = data;
set((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
failedJobCount,
status: queueStatus,
},
}));
});
socket.on("LS_COVER_EXTRACTED", ({ completedJobCount, importResult }) => {
set((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
mostRecentImport: importResult.data.rawFileDetails.name,
},
}));
});
socket.on("LS_COVER_EXTRACTION_FAILED", ({ failedJobCount }) => {
set((state) => ({
importJobQueue: {
...state.importJobQueue,
failedJobCount,
},
}));
});
socket.on("LS_IMPORT_QUEUE_DRAINED", () => {
localStorage.removeItem("sessionId");
set((state) => ({
importJobQueue: {
...state.importJobQueue,
status: "drained",
},
}));
const queryClientRef = get().queryClientRef;
if (queryClientRef?.current) {
queryClientRef.current.invalidateQueries({ queryKey: ["allImportJobResults"] });
}
});
socket.on("CV_SCRAPING_STATUS", (data) => {
set((state) => ({
comicvine: {
...state.comicvine,
scrapingStatus: data.message,
},
}));
});
socket.on("searchResultsAvailable", (data) => {
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
});
set((state) => ({
socketInstances: {
...state.socketInstances,
[namespace]: socket,
},
}));
return socket;
},
disconnectSocket: (namespace: string) => {
const socket = get().socketInstances[namespace];
if (socket) {
socket.disconnect();
set((state) => {
const { [namespace]: _, ...rest } = state.socketInstances;
return { socketInstances: rest };
});
}
},
comicvine: {
scrapingStatus: "",
},
// Import job queue and associated statuses
importJobQueue: { importJobQueue: {
successfulJobCount: 0, successfulJobCount: 0,
failedJobCount: 0, failedJobCount: 0,
status: undefined, status: undefined,
setStatus: (status: string) =>
set(
produce((draftState) => {
draftState.importJobQueue.status = status;
}),
),
setJobCount: (jobType: string, count: Number) => {
switch (jobType) {
case "successful":
set(
produce((draftState) => {
draftState.importJobQueue.successfulJobCount = count;
}),
);
break;
case "failed":
set(
produce((draftState) => {
draftState.importJobQueue.failedJobCount = count;
}),
);
break;
}
},
mostRecentImport: null, mostRecentImport: null,
setMostRecentImport: (fileName: string) => {
set( setStatus: (status: string) =>
produce((state) => { set((state) => ({
state.importJobQueue.mostRecentImport = fileName; importJobQueue: {
}), ...state.importJobQueue,
); status,
}, },
})),
setJobCount: (jobType: string, count: number) =>
set((state) => ({
importJobQueue: {
...state.importJobQueue,
...(jobType === "successful"
? { successfulJobCount: count }
: { failedJobCount: count }),
},
})),
setMostRecentImport: (fileName: string) =>
set((state) => ({
importJobQueue: {
...state.importJobQueue,
mostRecentImport: fileName,
},
})),
}, },
})); }));
const { getState, setState } = useStore;
/** Socket.IO initialization **/
// 1. Fetch sessionId from localStorage
const sessionId = localStorage.getItem("sessionId");
// 2. socket.io instantiation
const socketIOInstance = io(SOCKET_BASE_URI, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
// 3. Set the instance in global state
setState({
socketIOInstance,
});
// Socket.io-based session restoration
if (!isNil(sessionId)) {
// 1. Resume the session
socketIOInstance.emit(
"call",
"socket.resumeSession",
{
sessionId,
},
(data) => console.log(data),
);
} else {
// 1. Inititalize the session and persist the sessionId to localStorage
socketIOInstance.on("sessionInitialized", (sessionId) => {
localStorage.setItem("sessionId", sessionId);
});
}
// 2. If a job is in progress, restore the job counts and persist those to global state
socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
console.log("Active import in progress detected; restoring counts...");
const { completedJobCount, failedJobCount, queueStatus } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
failedJobCount,
status: queueStatus,
},
}));
});
/**
* Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection
* @returns Initialized AirDC++ connection socket instance
*/
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
try {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
setState({
airDCPPSessionInformation,
});
// Set up event listeners
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
console.log(downloadProgressData);
setState({
airDCPPDownloadTick: downloadProgressData,
});
},
);
initializedAirDCPPSocket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
count += 1;
}
}
},
);
return initializedAirDCPPSocket;
} catch (error) {
console.error(error);
}
};
// 1. get settings from mongo
const { data } = await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
});
const directConnectConfiguration = data?.directConnect?.client.host;
// 2. If available, init AirDC++ Socket with those settings
if (!isNil(directConnectConfiguration)) {
const airDCPPSocketInstance = await initializeAirDCPPSocket(
directConnectConfiguration,
);
setState({
airDCPPSocketInstance,
airDCPPClientConfiguration: directConnectConfiguration,
});
} else {
console.log("problem");
}
console.log("connected?", getState());

25
tailwind.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { addDynamicIconSelectors } from "@iconify/tailwind";
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
fontFamily: {
sans: ["PP Object Sans Regular", "sans-serif"],
hasklig: ["Hasklig Regular", "monospace"],
},
container: {
center: true,
padding: {
DEFAULT: "1rem",
sm: "2rem",
lg: "4rem",
xl: "5rem",
"2xl": "6rem",
},
},
},
plugins: [addDynamicIconSelectors()],
};

4233
yarn.lock

File diff suppressed because it is too large Load Diff