Compare commits

...

126 Commits

Author SHA1 Message Date
0949ebc637 📃 WIP bottom sheet 2026-04-03 10:55:10 -04:00
3e045f4c10 📃 Added a bottom sheet to Metadata info 2026-04-02 23:44:03 -04:00
17db1e64e1 🔨 Added JsDoc, WIP metadata reconciliation 2026-04-02 18:29:40 -04:00
d7ab553120 🔨 Clearning up the VolumeInformation tab 2026-04-02 13:07:13 -04:00
91592019c4 📗 Updated README 2026-03-26 20:55:23 -04:00
0e8f63101c 🤺 ComicVine matching in drawer UX fixed 2026-03-26 20:50:27 -04:00
4e2cad790b 🕶Added visibility guards to volumes, stats and wanted on dashboard 2026-03-09 23:11:15 -04:00
ba1b5bb965 🔨 Fix for showing accurate import counts 2026-03-09 22:54:47 -04:00
8546641152 🖌 Icon fixes 2026-03-09 22:31:59 -04:00
867935be39 📛 Added missing files to lib stats on Dashboard 2026-03-09 21:54:21 -04:00
d506cf8ba8 🔢 Fix for filesize on disk on Dashboard 2026-03-09 21:33:21 -04:00
71d7034d01 🎫 Fixed download badges in T2Table 2026-03-09 20:39:58 -04:00
a217d447fa 🔨 Added missing file statuses on Dashboard and Library 2026-03-09 19:55:16 -04:00
20336e5569 🔍 Missing files statuses in the UI 2026-03-09 17:10:18 -04:00
8913e9cd99 🔨 ComicDetail grqphQL refactor 2026-03-09 11:44:24 -04:00
c392333170 🛠 Missing files logic 2026-03-07 21:52:26 -05:00
e083c18c0e 🔨 Removed useless build-deps 2026-03-06 14:28:03 -05:00
7818c6f290 🛠 Build optimizations (chunking and lazy-loading) 2026-03-06 12:37:45 -05:00
46e683859e 🔨 Fixed warnings in yarn dev script 2026-03-06 10:50:19 -05:00
a45eae2604 Removed redundant server code 2026-03-05 22:18:20 -05:00
a0d971e010 🔨 Fixed the status updates on Import 2026-03-05 21:25:01 -05:00
aec989d021 🏗 Added a real time import stats panel 2026-03-05 12:39:16 -05:00
2b4ee716e3 🪓 Removed apollo-client 2026-03-05 11:55:46 -05:00
ec52906eca 🔨 Fixed timestamps 2026-03-05 11:48:43 -05:00
07f5e6efe6 ⬇ Import flow graph-qlified 2026-03-05 11:41:10 -05:00
5d18bd1e43 📔 JsDoc for index.ts 2026-03-05 11:00:56 -05:00
74c0d6513c 🐘 Massive refactor for graphql changes 2026-03-04 23:42:50 -05:00
4b8d7b5905 ✂ Refactoring ComicDetail to make it... readable? 2026-02-26 14:06:37 -05:00
92992449a9 📃 Fixed comicvine matching UI 2026-02-26 13:51:53 -05:00
59afeded6a 🔨 Fixes to ComicDetail container 2026-02-25 20:52:40 -05:00
f9aac5e19f 🎠 Carousels on Wanted and Volumes 2026-02-25 20:33:33 -05:00
a8ae4130a6 🔨 Fixes for RecentlyImported Section 2026-02-25 20:27:12 -05:00
6f781af381 📏 Alignment fixes 2026-02-25 20:15:29 -05:00
c005d118ac 🎨 Styling fixes in wake of tailwindv4 2026-02-25 17:38:27 -05:00
4498830e29 🛻 Upgraded deps, fixed issues 2026-02-25 16:57:46 -05:00
e113066094 🤡 Unit testing scaffold 2026-02-25 09:18:28 -05:00
0af9482be9 📝 State issue fixes 2026-02-24 15:48:38 -05:00
37a2d0c75b 📏 Fixes for responsiveness 2026-02-17 20:29:03 -05:00
b47b38cc8d 🔨 Fixes for broken cover images in RI 2026-02-17 12:07:34 -05:00
dependabot[bot]
5c99cfb28b Bump react-router from 7.5.2 to 7.12.0 (#145)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.5.2 to 7.12.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 21:42:24 -05:00
dependabot[bot]
880b6c44ff Bump lodash from 4.17.21 to 4.17.23 (#146)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 21:42:11 -05:00
b0c8c295c7 🔧 Fixed embla carousel bleed-off 2026-02-03 20:58:36 -05:00
d39e2cdca1 🛠 Switched to embla and made carousel bleed off page 2026-01-30 17:03:23 -05:00
dependabot[bot]
1a6154e0b4 Bump storybook from 7.6.10 to 7.6.21 (#142)
Bumps [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core) from 7.6.10 to 7.6.21.
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v7.6.21/code/core)

---
updated-dependencies:
- dependency-name: storybook
  dependency-version: 7.6.21
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 16:10:16 -05:00
dependabot[bot]
d8e110e2e7 Bump axios-cache-interceptor from 1.5.1 to 1.11.1 (#143)
Bumps [axios-cache-interceptor](https://github.com/arthurfiorette/axios-cache-interceptor) from 1.5.1 to 1.11.1.
- [Release notes](https://github.com/arthurfiorette/axios-cache-interceptor/releases)
- [Changelog](https://github.com/arthurfiorette/axios-cache-interceptor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/arthurfiorette/axios-cache-interceptor/compare/v1.5.1...v1.11.1)

---
updated-dependencies:
- dependency-name: axios-cache-interceptor
  dependency-version: 1.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 16:10:01 -05:00
dependabot[bot]
9189386593 Bump qs from 6.13.0 to 6.14.1 (#144)
Bumps [qs](https://github.com/ljharb/qs) from 6.13.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 16:09:49 -05:00
a10cb07d67 🔧 Fixed the dark mode switch 2025-12-02 17:05:58 -05:00
dependabot[bot]
2a83855115 Bump tar-fs from 2.1.3 to 2.1.4 (#140) 2025-11-21 08:25:27 -05:00
dependabot[bot]
58b60a38b4 Bump vite from 5.4.20 to 5.4.21 (#141) 2025-11-21 08:25:10 -05:00
dependabot[bot]
c339dc9df1 Bump vite from 5.4.19 to 5.4.20 (#138)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.19 to 5.4.20.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.20/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.20/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.20
  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-09-23 18:16:31 -04:00
dependabot[bot]
d8bcad88b7 Bump axios from 1.8.2 to 1.12.0 (#139)
Bumps [axios](https://github.com/axios/axios) from 1.8.2 to 1.12.0.
- [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.8.2...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  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-09-23 18:16:17 -04:00
dependabot[bot]
ff1742998a Bump form-data from 4.0.0 to 4.0.4 (#137) 2025-09-06 18:43:09 -04:00
dependabot[bot]
de1fb349f6 Bump cross-spawn from 7.0.3 to 7.0.6 (#124)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 13:47:30 -04:00
dependabot[bot]
698e2a89da Bump store2 from 2.14.2 to 2.14.4 (#126)
Bumps [store2](https://github.com/nbubna/store) from 2.14.2 to 2.14.4.
- [Commits](https://github.com/nbubna/store/compare/2.14.2...2.14.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 13:47:14 -04:00
dependabot[bot]
0597180637 Bump @babel/runtime from 7.23.9 to 7.26.10 (#129)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.23.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 13:47:03 -04:00
dependabot[bot]
422b16abf8 Bump @babel/runtime-corejs3 from 7.23.9 to 7.26.10 (#130)
Bumps [@babel/runtime-corejs3](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime-corejs3) from 7.23.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime-corejs3)

---
updated-dependencies:
- dependency-name: "@babel/runtime-corejs3"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 13:46:51 -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
dependabot[bot]
68442894d0 Bump vite from 5.0.2 to 5.0.5 (#99)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.2 to 5.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.5/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>
2023-12-06 22:41:14 -06:00
dba520b4c1 🧸 Zustand and Tanstack Query (#96)
* ↪️ Removed node-sass, added sass

* 🏗️ Refactoring Navbar to read from zustand store

* ⬆️ Bumped deps

* 🏗️ Refactored AirDC++ session status indicator

* 🏗️ Refactored Import page to read from global state

* 🏗 Wired up the event emit correctly

* 🏗️ Added import queue related state

* 🏗 Implemented setQueueAction

* 🏗️ Wired up job queue control methods

* 🏗️ Added null check and removed useless deps

* 🏗️ Refactored the Import page

* ↪️ Added cache invalidation to job statistics query

* 🏗️ Refactoring the Library page

* 🏗️ Fixed pagination and disabled states

* ✏️ Changed page to offset

To better reflect what we are doing with the pagination controls

* 🏗️ Refactoring ComicDetail page and its children

* 🏗️ Refactored ComicDetailContainer with useQuery

* 🔧 Fixed the error check on Library page

* 🏗️ Refactoring AcquisitionPanel

* 🏗️ Refactoring the AirDC++ Forms

* 🦃 Thanksgiving Day bug fixes

* ⬆️ Bumped up Vite to 5.0

* 🔧 Refactoring AcquisitionPanel

* 🏗️ Wiring up the DC++ search method

* 🏗️ Refactoring AirDC++ search method

* 🔎 Added some validation to ADC++ Hubs settings form

* 🏗️ Fixed the ADC++ search results

* 🏗️ Cleanup of the search results pane
2023-11-28 22:54:45 -05:00
ef75dad4e2 Adding zustand and react-query (Settings Page) (#95)
* 🌊 qBittorrent settings scaffold

* 🔧 Added scaffold for the qBittorrent connection form

* 🔧 Some refactoring

* 🔧 Cleaned up folder structure

* 🔧 Fixed broken paths

* 🔧 Cleaned up Search and Import component hierarchy

* 🔧 More path fixes

* 🔧 Tooling changes

* 📝 Qbittorrent form scaffold

* ⬆️ Bumped @dnd-kit deps

* 🧑🏼‍🔧 Fixed the hostname regex

* 🏗️ Adding fields to the settings form

* 🔧 Formatting and more layout changes

* 🔧 Added Prowlarr settings items in JSON

* 📝 Purified Card Component

* 📝 Abstracted connection form into a component

* 🏗️ Reorganized tabs

* Migrating from Redux to RTK-query

* ⬇️ Fetched qBittorrent settings

* 🏗️ Trying out react-query

* 🧩 Added react-query query to qBittorrentSettings page

* 📝 qbittorrent form RU actions first draft

* 🏗️ Added loading state check

* 🏗 Added error check state

* 🏗️ Refactored AirDCPP context using react-query

* 🏗️ Refactoring AirDCPP Settings Form with react-query

* 🔧 Removed context

* 🔧 Removing context from AirDCPP settings page

* 🔧 Fixed early init error on the store

* 🐛 Debugging AirDCPP Settings Form page

* 🧸 Zustand-ified AirDCPP Form

*  AirDCPP code cleaned up from App.tsx

*  Re-added yarn.lock
2023-11-07 11:56:29 -06:00
8bebffd95e 🌊 qBittorrent Settings Scaffold (#90)
* 🌊 qBittorrent settings scaffold

* 🔧 Added scaffold for the qBittorrent connection form

* 🔧 Some refactoring

* 🔧 Cleaned up folder structure

* 🔧 Fixed broken paths

* 🔧 Cleaned up Search and Import component hierarchy

* 🔧 More path fixes

* 🔧 Tooling changes

* 📝 Qbittorrent form scaffold

* ⬆️ Bumped @dnd-kit deps

* 🧑🏼‍🔧 Fixed the hostname regex

* 🏗️ Adding fields to the settings form

* 🔧 Formatting and more layout changes

* 🔧 Added Prowlarr settings items in JSON

* 📝 Purified Card Component

* 📝 Abstracted connection form into a component

* 🏗️ Reorganized tabs

* Migrating from Redux to RTK-query

* ⬇️ Fetched qBittorrent settings

* 🏗️ Trying out react-query

* 🧩 Added react-query query to qBittorrentSettings page

* 📝 qbittorrent form RU actions first draft

* 🏗️ Added loading state check

* 🏗 Added error check state

* 🏗️ Refactored AirDCPP context using react-query

* 🏗️ Refactoring AirDCPP Settings Form with react-query

* 🔧 Removed context

* 🔧 Removing context from AirDCPP settings page

* 🔧 Fixed early init error on the store

* 🐛 Debugging AirDCPP Settings Form page

* 🧸 Zustand-ified AirDCPP Form

*  AirDCPP code cleaned up from App.tsx

*  Re-added yarn.lock
2023-11-07 11:46:08 -06:00
dependabot[bot]
1bd3d611e4 Bump postcss from 8.4.29 to 8.4.31 (#93) 2023-10-19 08:37:49 -05:00
dependabot[bot]
825782fe13 Bump @babel/traverse from 7.22.11 to 7.23.2 (#94) 2023-10-19 08:37:41 -05:00
32f4055daa Import queue progress (#87)
* 🚥 Added service status panel scaffold

* 🐂 Support for showing import progress

* 🐂 Support for session-tracking

* 🔧 Tooling for resumable socket.io sessions

* 🧹 Minor change in socket.io connection code

* 🔧 Refactoring the Import Page

* 📝 Added more details to import statuses

* 🐂 Queue pause/resume functionality

* 🐂 Queue drain event reducer

* 🐂 Queue controls

* 🔧 Hardening the import UX

* 🔀 Bumped deps

* 🔧 Fixed the airdcpp-apisocket version

* ⛑️ Removed useless deps

* 🪡 Fixed margin on the comicinfo.xml panel on the library page

* 🏗️ Scaffold for job results

* 🔢 Removed the useless LS_IMPORT event

* 🔧 Wired up jobStatistics call

* 🧹 Cleaning up the tabulated job results

* 🔧 More finishing touches to Import UX

* 🔧 Added a console log for debugging purposes

---------

Co-authored-by: Rishi Ghan <hghan@apple.com>
2023-08-30 13:49:58 -04:00
dependabot[bot]
c20f24b1a2 Bump word-wrap from 1.2.3 to 1.2.4 (#88)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-11 14:11:53 -04:00
dependabot[bot]
e4fc28f698 Bump tough-cookie from 4.0.0 to 4.1.3 (#85)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-12 10:52:32 -07:00
dependabot[bot]
d6c183339f Bump semver from 5.7.1 to 5.7.2 (#86)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-12 10:52:20 -07:00
dependabot[bot]
0eba47e20f Bump vite from 4.2.0 to 4.2.3 (#82) 2023-06-05 22:37:43 -04:00
928bfd573e 🔧 Fix for the hostname env var when running in Docker (#81) 2023-05-16 12:41:57 -04:00
b8fb179ac6 🔧 Miscellaneous Fixes (#79)
* ️ Config key to expose hostname in a Docker context

* 🔧 Miscellaneous Fixes
2023-05-15 16:16:26 -04:00
94692bb6d4 71 dockerfile update (#75)
* 🐳 Fixes to the Dockerfile

* 🚢 Updated port number for UI

*  Removed the param serialization from axios calls
2023-05-15 16:13:40 -04:00
4b795aca5d ️ Config key to expose hostname in a Docker context (#77) 2023-04-27 11:38:50 -04:00
fbf6bed4fe 🐳 Fixes to the Dockerfile (#74) 2023-03-31 15:09:23 -04:00
Lars Gohr
18b18c3d81 Updated elgohr/Publish-Docker-Github-Action to a supported version (v5) (#73) 2023-03-30 21:00:12 -04:00
a939bf4c71 🐛UI bugs (#68)
* 🔧 Updated date for PullList on Dashboard

* 🔧 Fixes for broken image paths

* ⬆️ Bumped deps, removed useless ones
2023-03-24 00:00:48 -04:00
0a48ecbb2c 📗 Upgraded Storybook to v7 rc (#69) 2023-03-15 16:43:16 -04:00
c5dd1abcdd 🖼️ Relocating screenshots (#70)
* 🔧 Updated date for PullList on Dashboard

* 🔧 Fixes for broken image paths

* 🖼️ Updated screenshot links in README
2023-03-15 14:04:49 -04:00
79f9b22fad Elasticsearch upgrade fix (#67)
* 🔧 Fixed the response object in reducers and components https://github.com/rishighan/threetwo/issues/64

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* #️⃣ Added a key prop to MetadataPanel in global search results

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixed DOMNesting issues

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixed the response in wanted reducer action

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

*  Committing the reducer

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixes for DOMNesting issues on the Downloads page

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

---------

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>
2023-03-13 11:25:15 -04:00
dependabot[bot]
e291b4806b Bump http-cache-semantics from 4.1.0 to 4.1.1 (#65)
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-02 21:47:16 -05:00
ff7b7c9207 Fix for elasticsearch upgrade breakage (#66)
* 🔧 Fixed the response object in reducers and components https://github.com/rishighan/threetwo/issues/64

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* #️⃣ Added a key prop to MetadataPanel in global search results

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixed DOMNesting issues

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixed the response in wanted reducer action

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

*  Committing the reducer

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

* 🔧 Fixes for DOMNesting issues on the Downloads page

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

---------

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>
2023-03-02 00:32:43 -05:00
2d61fa1436 Changing the build system to Vite (#62)
* 🔧 Updated date for PullList on Dashboard

* ️ Added Vite config and removed useless files

* 👷🏽 Updated build command

*  Removed useless deps

* 🔧 Cleaned up package.json and bumped airdcpp-apisocket

* 🔧 Updated some packages and deps

* ⬆️ Bumped some deps

* 🔧 Fixed typo in package.json

* 🔧 Fix for broken paths https://github.com/rishighan/threetwo/issues/63

* 🔧 Fixed broken path and npm script

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>

---------

Signed-off-by: Rishi Ghan <rishi.ghan@gmail.com>
2023-03-01 23:31:22 -05:00
b12849efda 🔧 Fixed the path to funding.yml (#61) 2023-01-28 09:42:39 -08:00
173 changed files with 39948 additions and 23006 deletions

View File

@@ -1,17 +0,0 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
"@babel/transform-runtime",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-syntax-top-level-await"
],
"env": {
"production": {
"presets": ["minify"]
}
}
}

View File

@@ -0,0 +1,379 @@
---
name: jsdoc
description: Commenting and documentation guidelines. Auto-activate when the user discusses comments, documentation, docstrings, code clarity, API docs, JSDoc, or asks about commenting strategies.
---
Auto-activate when: User discusses comments, documentation, docstrings, code clarity, code quality, API docs, JSDoc, Python docstrings, or asks about commenting strategies.
Core Principle
Write code that speaks for itself. Comment only when necessary to explain WHY, not WHAT.
Most code does not need comments. Well-written code with clear naming and structure is self-documenting.
The best comment is the one you don't need to write because the code is already obvious.
The Commenting Philosophy
When to Comment
✅ DO comment when explaining:
WHY something is done (business logic, design decisions)
Complex algorithms and their reasoning
Non-obvious trade-offs or constraints
Workarounds for bugs or limitations
API contracts and public interfaces
Regex patterns and what they match
Performance considerations or optimizations
Constants and magic numbers
Gotchas or surprising behaviors
❌ DON'T comment when:
The code is obvious and self-explanatory
The comment repeats the code (redundant)
Better naming would eliminate the need
The comment would become outdated quickly
It's decorative or organizational noise
It states what a standard language construct does
Comment Anti-Patterns
❌ 1. Obvious Comments
BAD:
counter = 0 # Initialize counter to zero
counter += 1 # Increment counter by one
user_name = input("Enter name: ") # Get user name from input
Better: No comment needed - the code is self-explanatory.
❌ 2. Redundant Comments
BAD:
def get_user_name(user):
return user.name # Return the user's name
def calculate_total(items):
# Loop through items and sum the prices
total = 0
for item in items:
total += item.price
return total
Better:
def get_user_name(user):
return user.name
def calculate_total(items):
return sum(item.price for item in items)
❌ 3. Outdated Comments
BAD:
# Calculate tax at 5% rate
tax = price * 0.08 # Actually 8%, comment is wrong
# DEPRECATED: Use new_api_function() instead
def old_function(): # Still being used, comment is misleading
pass
Better: Keep comments in sync with code, or remove them entirely.
❌ 4. Noise Comments
BAD:
# Start of function
def calculate():
# Declare variable
result = 0
# Return result
return result
# End of function
Better: Remove all of these comments.
❌ 5. Dead Code & Changelog Comments
BAD:
# Don't comment out code - use version control
# def old_function():
# return "deprecated"
# Don't maintain history in comments
# Modified by John on 2023-01-15
# Fixed bug reported by Sarah on 2023-02-03
Better: Delete the code. Git has the history.
Good Comment Examples
✅ Complex Business Logic
# Apply progressive tax brackets: 10% up to $10k, 20% above
# This matches IRS publication 501 for 2024
def calculate_progressive_tax(income):
if income <= 10000:
return income * 0.10
else:
return 1000 + (income - 10000) * 0.20
✅ Non-obvious Algorithms
# Using Floyd-Warshall for all-pairs shortest paths
# because we need distances between all nodes.
# Time: O(n³), Space: O(n²)
for k in range(vertices):
for i in range(vertices):
for j in range(vertices):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
✅ Regex Patterns
# Match email format: username@domain.extension
# Allows letters, numbers, dots, hyphens in username
# Requires valid domain and 2+ char extension
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
✅ API Constraints or Gotchas
# GitHub API rate limit: 5000 requests/hour for authenticated users
# We implement exponential backoff to handle rate limiting
await rate_limiter.wait()
response = await fetch(github_api_url)
✅ Workarounds for Bugs
# HACK: Workaround for bug in library v2.1.0
# Remove after upgrading to v2.2.0
# See: https://github.com/library/issues/123
if library_version == "2.1.0":
apply_workaround()
Decision Framework
Before writing a comment, ask yourself:
Step 1: Is the code self-explanatory?
If YES → No comment needed
If NO → Continue to step 2
Step 2: Would a better variable/function name eliminate the need?
If YES → Refactor the code instead
If NO → Continue to step 3
Step 3: Does this explain WHY, not WHAT?
If explaining WHAT → Refactor code to be clearer
If explaining WHY → Good comment candidate
Step 4: Will this help future maintainers?
If YES → Write the comment
If NO → Skip it
Special Cases for Comments
Public APIs and Docstrings
Python Docstrings
def calculate_compound_interest(
principal: float,
rate: float,
time: int,
compound_frequency: int = 1
) -> float:
"""
Calculate compound interest using the standard formula.
Args:
principal: Initial amount invested
rate: Annual interest rate as decimal (e.g., 0.05 for 5%)
time: Time period in years
compound_frequency: Times per year interest compounds (default: 1)
Returns:
Final amount after compound interest
Raises:
ValueError: If any parameter is negative
Example:
>>> calculate_compound_interest(1000, 0.05, 10)
1628.89
"""
if principal < 0 or rate < 0 or time < 0:
raise ValueError("Parameters must be non-negative")
# Compound interest formula: A = P(1 + r/n)^(nt)
return principal * (1 + rate / compound_frequency) ** (compound_frequency * time)
JavaScript/TypeScript JSDoc
/**
* Fetch user data from the API.
*
* @param {string} userId - The unique user identifier
* @param {Object} options - Configuration options
* @param {boolean} options.includeProfile - Include profile data (default: true)
* @param {number} options.timeout - Request timeout in ms (default: 5000)
*
* @returns {Promise<User>} User object with requested fields
*
* @throws {Error} If userId is invalid or request fails
*
* @example
* const user = await fetchUser('123', { includeProfile: true });
*/
async function fetchUser(userId, options = {}) {
// Implementation
}
Constants and Configuration
# Based on network reliability studies (95th percentile)
MAX_RETRIES = 3
# AWS Lambda timeout is 15s, leaving 5s buffer for cleanup
API_TIMEOUT = 10000 # milliseconds
# Cache duration optimized for balance between freshness and load
# See: docs/performance-tuning.md
CACHE_TTL = 300 # 5 minutes
Annotations for TODOs and Warnings
# TODO: Replace with proper authentication after security review
# Issue: #456
def temporary_auth(user):
return True
# WARNING: This function modifies the original array instead of creating a copy
def sort_in_place(arr):
arr.sort()
return arr
# FIXME: Memory leak in production - investigate connection pooling
# Ticket: JIRA-789
def get_connection():
return create_connection()
# PERF: Consider caching this result if called frequently in hot path
def expensive_calculation(data):
return complex_algorithm(data)
# SECURITY: Validate input to prevent SQL injection before using in query
def build_query(user_input):
sanitized = escape_sql(user_input)
return f"SELECT * FROM users WHERE name = '{sanitized}'"
Common Annotation Keywords
TODO: - Work that needs to be done
FIXME: - Known bugs that need fixing
HACK: - Temporary workarounds
NOTE: - Important information or context
WARNING: - Critical information about usage
PERF: - Performance considerations
SECURITY: - Security-related notes
BUG: - Known bug documentation
REFACTOR: - Code that needs refactoring
DEPRECATED: - Soon-to-be-removed code
Refactoring Over Commenting
Instead of Commenting Complex Code...
BAD: Complex code with comment
# Check if user is admin or has special permissions
if user.role == "admin" or (user.permissions and "special" in user.permissions):
grant_access()
...Extract to Named Function
GOOD: Self-explanatory through naming
def user_has_admin_access(user):
return user.role == "admin" or has_special_permission(user)
def has_special_permission(user):
return user.permissions and "special" in user.permissions
if user_has_admin_access(user):
grant_access()
Language-Specific Examples
JavaScript
// Good: Explains WHY we debounce
// Debounce search to reduce API calls (500ms wait after last keystroke)
const debouncedSearch = debounce(searchAPI, 500);
// Bad: Obvious
let count = 0; // Initialize count to zero
count++; // Increment count
// Good: Explains algorithm choice
// Using Set for O(1) lookup instead of Array.includes() which is O(n)
const seen = new Set(ids);
Python
# Good: Explains the algorithm choice
# Using binary search because data is sorted and we need O(log n) performance
index = bisect.bisect_left(sorted_list, target)
# Bad: Redundant
def get_total(items):
return sum(items) # Return the sum of items
# Good: Explains why we're doing this
# Extract to separate function for type checking in mypy
def validate_user(user):
if not user or not user.id:
raise ValueError("Invalid user")
return user
TypeScript
// Good: Explains the type assertion
// TypeScript can't infer this is never null after the check
const element = document.getElementById('app') as HTMLElement;
// Bad: Obvious
const sum = a + b; // Add a and b
// Good: Explains non-obvious behavior
// spread operator creates shallow copy; use JSON for deep copy
const newConfig = { ...config };
Comment Quality Checklist
Before committing, ensure your comments:
Explain WHY, not WHAT
Are grammatically correct and clear
Will remain accurate as code evolves
Add genuine value to code understanding
Are placed appropriately (above the code they describe)
Use proper spelling and professional language
Follow team conventions for annotation keywords
Could not be replaced by better naming or structure
Are not obvious statements about language features
Reference tickets/issues when applicable
Summary
Priority order:
Clear code - Self-explanatory through naming and structure
Good comments - Explain WHY when necessary
Documentation - API docs, docstrings for public interfaces
No comments - Better than bad comments that lie or clutter
Remember: Comments are a failure to make the code self-explanatory. Use them sparingly and wisely.
Key Takeaways
Goal Approach
Reduce comments Improve naming, extract functions, simplify logic
Improve clarity Use self-explanatory code structure, clear variable names
Document APIs Use docstrings/JSDoc for public interfaces
Explain WHY Comment only business logic, algorithms, workarounds
Maintain accuracy Update comments when code changes, or remove them

View File

@@ -0,0 +1,353 @@
---
name: typescript
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
---
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
Naming Conventions
Type Convention Example
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
Variables, Parameters, Functions lowerCamelCase userName, processData
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
Type Parameters Single letter or UpperCamelCase T, ResponseType
Naming Principles
Descriptive names, avoid ambiguous abbreviations
Treat acronyms as words: loadHttpUrl not loadHTTPURL
No prefixes like opt_ for optional parameters
No trailing underscores for private properties
Single-letter variables only when scope is <10 lines
Variable Declarations
// Always use const by default
const users = getUsers();
// Use let only when reassignment is needed
let count = 0;
count++;
// Never use var
// var x = 1; // WRONG
// One variable per declaration
const a = 1;
const b = 2;
// const a = 1, b = 2; // WRONG
Types and Interfaces
Prefer Interfaces Over Type Aliases
// Good: interface for object shapes
interface User {
id: string;
name: string;
email?: string;
}
// Avoid: type alias for object shapes
type User = {
id: string;
name: string;
};
// Type aliases OK for unions, intersections, mapped types
type Status = 'active' | 'inactive';
type Combined = TypeA & TypeB;
Type Inference
Leverage inference for trivially inferred types:
// Good: inference is clear
const name = 'Alice';
const items = [1, 2, 3];
// Good: explicit for complex expressions
const result: ProcessedData = complexTransformation(input);
Array Types
// Simple types: use T[]
const numbers: number[];
const names: readonly string[];
// Multi-dimensional: use T[][]
const matrix: number[][];
// Complex types: use Array<T>
const handlers: Array<(event: Event) => void>;
Null and Undefined
// Prefer optional fields over union with undefined
interface Config {
timeout?: number; // Good
// timeout: number | undefined; // Avoid
}
// Type aliases must NOT include |null or |undefined
type UserId = string; // Good
// type UserId = string | null; // WRONG
// May use == for null comparison (catches both null and undefined)
if (value == null) {
// handles both null and undefined
}
Types to Avoid
// Avoid any - use unknown instead
function parse(input: unknown): Data { }
// Avoid {} - use unknown, Record<string, T>, or object
function process(obj: Record<string, unknown>): void { }
// Use lowercase primitives
let name: string; // Good
// let name: String; // WRONG
// Never use wrapper objects
// new String('hello') // WRONG
Classes
Structure
class UserService {
// Fields first, initialized where declared
private readonly cache = new Map<string, User>();
private lastAccess: Date | null = null;
// Constructor with parameter properties
constructor(
private readonly api: ApiClient,
private readonly logger: Logger,
) {}
// Methods separated by blank lines
async getUser(id: string): Promise<User> {
// ...
}
private validateId(id: string): boolean {
// ...
}
}
Visibility
class Example {
// private by default, only use public when needed externally
private internalState = 0;
// readonly for properties never reassigned after construction
readonly id: string;
// Never use #private syntax - use TypeScript visibility
// #field = 1; // WRONG
private field = 1; // Good
}
Avoid Arrow Functions as Properties
class Handler {
// Avoid: arrow function as property
// handleClick = () => { ... };
// Good: instance method
handleClick(): void {
// ...
}
}
// Bind at call site if needed
element.addEventListener('click', () => handler.handleClick());
Static Methods
Never use this in static methods
Call on defining class, not subclasses
Functions
Prefer Function Declarations
// Good: function declaration for named functions
function processData(input: Data): Result {
return transform(input);
}
// Arrow functions when type annotation needed
const handler: EventHandler = (event) => {
// ...
};
Arrow Function Bodies
// Concise body only when return value is used
const double = (x: number) => x * 2;
// Block body when return should be void
const log = (msg: string) => {
console.log(msg);
};
Parameters
// Use rest parameters, not arguments
function sum(...numbers: number[]): number {
return numbers.reduce((a, b) => a + b, 0);
}
// Destructuring for multiple optional params
interface Options {
timeout?: number;
retries?: number;
}
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
// ...
}
// Never name a parameter 'arguments'
Imports and Exports
Always Use Named Exports
// Good: named exports
export function processData() { }
export class UserService { }
export interface Config { }
// Never use default exports
// export default class UserService { } // WRONG
Import Styles
// Module import for large APIs
import * as fs from 'fs';
// Named imports for frequently used symbols
import { readFile, writeFile } from 'fs/promises';
// Type-only imports when only used as types
import type { User, Config } from './types';
Module Organization
Use modules, never namespace Foo { }
Never use require() - use ES6 imports
Use relative imports within same project
Avoid excessive ../../../
Control Structures
Always Use Braces
// Good
if (condition) {
doSomething();
}
// Exception: single-line if
if (condition) return early;
Loops
// Prefer for...of for arrays
for (const item of items) {
process(item);
}
// Use Object methods with for...of for objects
for (const [key, value] of Object.entries(obj)) {
// ...
}
// Never use unfiltered for...in on arrays
Equality
// Always use === and !==
if (a === b) { }
// Exception: == null catches both null and undefined
if (value == null) { }
Switch Statements
switch (status) {
case Status.Active:
handleActive();
break;
case Status.Inactive:
handleInactive();
break;
default:
// Always include default, even if empty
break;
}
Exception Handling
// Always throw Error instances
throw new Error('Something went wrong');
// throw 'error'; // WRONG
// Catch with unknown type
try {
riskyOperation();
} catch (e: unknown) {
if (e instanceof Error) {
logger.error(e.message);
}
throw e;
}
// Empty catch needs justification comment
try {
optional();
} catch {
// Intentionally ignored: fallback behavior handles this
}
Type Assertions
// Use 'as' syntax, not angle brackets
const input = value as string;
// const input = <string>value; // WRONG in TSX, avoid everywhere
// Double assertion through unknown when needed
const config = (rawData as unknown) as Config;
// Add comment explaining why assertion is safe
const element = document.getElementById('app') as HTMLElement;
// Safe: element exists in index.html
Strings
// Use single quotes for string literals
const name = 'Alice';
// Template literals for interpolation or multiline
const message = `Hello, ${name}!`;
const query = `
SELECT *
FROM users
WHERE id = ?
`;
// Never use backslash line continuations
Disallowed Features
Feature Alternative
var const or let
Array() constructor [] literal
Object() constructor {} literal
any type unknown
namespace modules
require() import
Default exports Named exports
#private fields private modifier
eval() Never use
const enum Regular enum
debugger Remove before commit
with Never use
Prototype modification Never modify

View File

@@ -1,14 +1,14 @@
module.exports = {
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true // Allows for the parsing of JSX
}
},
plugins: ["@typescript-eslint", "css-modules"],
settings: {
"import/resolver": {
@@ -18,9 +18,9 @@ module.exports = {
},
react: {
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
}
},
// Fine tune rules
rules: {
"@typescript-eslint/no-var-requires": 0

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@master
uses: elgohr/Publish-Docker-Github-Action@v5
with:
name: frishi/threetwo
username: ${{ secrets.DOCKER_USERNAME }}

1
.gitignore vendored
View File

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

View File

@@ -1,19 +0,0 @@
module.exports = {
stories: [
"../src/client/stories/*.stories.mdx",
"../src/client/stories/*.stories.@(js|jsx|ts|tsx)",
],
staticDirs: [
"../src/client/stories/assets"
],
addons: [
"@storybook/addon-links",
"@storybook/preset-scss",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
],
framework: "@storybook/react",
core: {
builder: "webpack5",
},
};

19
.storybook/main.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;

View File

@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>

View File

@@ -1,9 +0,0 @@
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}

18
.storybook/preview.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
backgrounds: {
default: "light",
},
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

View File

@@ -1,19 +1,36 @@
FROM node:17.3-alpine
# Use Node.js 22 as the base image
FROM node:22-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Set the working directory inside the container
WORKDIR /threetwo
COPY package.json ./
COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# Copy package.json and yarn.lock to leverage Docker cache
COPY package.json yarn.lock ./
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
RUN apk --no-cache add g++ make libpng-dev python3 git libc6-compat autoconf automake bash libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
# Install build dependencies necessary for native modules (for node-sass)
RUN apk --no-cache add \
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 . .
EXPOSE 3050
ENTRYPOINT [ "npm", "start" ]
# Expose the application port (default for Vite)
EXPOSE 5173
# Start the application with yarn
ENTRYPOINT ["yarn", "start"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots
![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/Dashboard.png)
#### Dashboard
![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/Library.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/DC%2B%2B%20integration.png)
#### Issue View
![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-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
@@ -28,7 +39,8 @@ ThreeTwo! currently is set up as:
1. The UI, this repo.
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-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
@@ -40,12 +52,11 @@ For debugging and troubleshooting, you can run this app locally using these step
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
2. `yarn run dev` (you can ignore the warnings)
3. This will open `http://localhost:3050` in your default browser
4. For testing `OPDS` functionality, create a folder called `comics` under `/src/server` and put some comics in there. The `OPDS` feed is accessed to `http://localhost:8050/api/opds`
5. 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.
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.
## Troubleshooting
### Docker
1. `docker-compose up` is taking a long time
@@ -56,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.
## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

1
__mocks__/fileMock.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

16
codegen.yml Normal file
View File

@@ -0,0 +1,16 @@
schema: http://localhost:3000/graphql
documents: 'src/client/graphql/**/*.graphql'
generates:
src/client/graphql/generated.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-query
config:
fetcher:
func: './fetcher#fetcher'
isReactHook: false
exposeFetcher: true
exposeQueryKeys: true
addInfiniteQuery: true
reactQueryVersion: 5

14
index.html Normal file
View File

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

28
jest.config.js Normal file
View File

@@ -0,0 +1,28 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)',
],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', {
tsconfig: {
jsx: 'react',
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
}],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
};

25
jest.setup.js Normal file
View File

@@ -0,0 +1,25 @@
require('@testing-library/jest-dom');
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;

View File

@@ -1,10 +1,6 @@
{
"tags": {
"allowUnknownTags": true,
"dictionaries": [
"jsdoc",
"closure"
]
"allowUnknownTags": false
},
"source": {
"include": [
@@ -13,22 +9,17 @@
"includePattern": "\\.(jsx|js|ts|tsx)$"
},
"plugins": [
"better-docs/component",
"better-docs/category",
"plugins/markdown",
"node_modules/better-docs/typescript"
"plugins/markdown"
],
"templates": {
"better-docs": {
"name": "ThreeTwo UI components"
}
},
"opts": {
"destination": "docs/",
"readme": "README.md",
"recurse": true,
"template": "node_modules/tui-jsdoc-template",
"encoding": "utf8",
"verbose": true,
"template": "node_modules/better-docs"
"destination": "docs/",
"recurse": true,
"verbose": true
},
"templates": {
"cleverLinks": false,
"monospaceLinks": false
}
}

View File

@@ -1,13 +0,0 @@
{
"ignore": [
"**/*.test.ts",
"**/*.spec.ts",
"node_modules",
"src/client"
],
"watch": [
"src/server"
],
"exec": "tsc -p tsconfig.server.json && node server/",
"ext": "ts"
}

19434
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,180 +1,150 @@
{
"name": "threetwo",
"version": "0.0.2",
"description": "ThreeTwo! A comic book curator.",
"main": "server/index.js",
"typings": "server/index.js",
"version": "0.1.0",
"description": "ThreeTwo! A good comic book curator.",
"scripts": {
"build": "webpack --mode production",
"start": "npm run build && npm run server",
"client": "webpack serve --mode development --devtool inline-source-map --hot",
"server": "tsc -p tsconfig.server.json && node server/",
"dev": "concurrently \"nodemon\" \"npm run client\"",
"server-dev": "nodemon",
"build": "vite build",
"dev": "rimraf dist && yarn build && vite",
"start": "yarn build && vite",
"docs": "jsdoc -c jsdoc.json",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
"knip": "knip"
},
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.17",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@dnd-kit/core": "^4.0.0",
"@dnd-kit/sortable": "^5.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.1.1",
"@parcel/config-default": "^2.6.0",
"@redux-devtools/extension": "^3.2.2",
"@tanstack/react-table": "^8.5.11",
"@types/mime-types": "^2.1.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/sharp": "^0.28.0",
"@types/socket.io": "^3.0.2",
"@types/socket.io-client": "^3.0.0",
"airdcpp-apisocket": "^2.4.4",
"array-sort-by": "^1.2.1",
"axios": "^0.27.2",
"axios-rate-limit": "^1.3.0",
"axios-simple-cache-adapter": "^1.1.0",
"babel-polyfill": "^6.26.0",
"babel-preset-minify": "^0.5.2",
"better-docs": "^2.7.2",
"comlink-loader": "^2.0.0",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.1.0",
"express": "^4.17.1",
"fastest-validator": "^1.11.0",
"filename-parser": "^1.0.2",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
"html-to-text": "^8.1.0",
"jsdoc": "^3.6.10",
"lodash": "^4.17.21",
"opds-extra": "^3.0.9",
"pretty-bytes": "^5.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.18",
"@floating-ui/react-dom": "^2.1.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"airdcpp-apisocket": "^3.0.0-beta.14",
"axios": "^1.13.5",
"axios-cache-interceptor": "^1.11.4",
"axios-rate-limit": "^1.6.2",
"babel-plugin-styled-components": "^2.1.4",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"ellipsize": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"filename-parser": "^1.0.4",
"final-form": "^5.0.0",
"final-form-arrays": "^4.0.0",
"focus-trap-react": "^12.0.0",
"graphql": "^16.13.1",
"history": "^5.3.0",
"html-to-text": "^9.0.5",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.2",
"immer": "^11.1.4",
"jsdoc": "^4.0.5",
"lodash": "^4.17.23",
"motion": "^12.38.0",
"pretty-bytes": "^7.1.0",
"prop-types": "^15.8.1",
"qs": "^6.10.5",
"react": "^18.2.0",
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.0.6",
"react-dom": "^18.1.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.3",
"react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"react-redux": "^7.2.6",
"react-router": "^6.2.2",
"react-router-dom": "^6.2.2",
"react-select": "^5.3.2",
"react-select-async-paginate": "^0.6.1",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.0.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.0.5",
"redux-first-history": "^5.0.9",
"redux-socket.io-middleware": "^1.0.4",
"redux-thunk": "^2.4.1",
"sharp": "^0.30.5",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^5.3.5",
"qs": "^6.15.0",
"react": "^19.2.4",
"react-collapsible": "^2.10.0",
"react-comic-viewer": "^0.5.1",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-fast-compare": "^3.2.2",
"react-final-form": "^7.0.0",
"react-final-form-arrays": "^4.0.0",
"react-i18next": "^16.5.4",
"react-loader-spinner": "^8.0.2",
"react-modal": "^3.16.3",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-select": "^5.10.2",
"react-select-async-paginate": "^0.7.11",
"react-sliding-pane": "^7.3.0",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^11.0.5",
"rxjs": "^7.8.2",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"threetwo-ui-typings": "^1.0.14",
"voca": "^1.4.0",
"websocket": "^1.0.34",
"ws": "^7.5.3",
"xml2js": "^0.4.23",
"xregexp": "^5.0.2"
"vaul": "^1.1.2",
"vite": "^7.3.1",
"vite-plugin-html": "^3.2.2",
"websocket": "^1.0.35",
"zustand": "^5.0.11"
},
"devDependencies": {
"@babel/cli": "^7.13.10",
"@babel/core": "^7.13.10",
"@babel/plugin-syntax-top-level-await": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.13.10",
"@babel/preset-react": "^7.12.13",
"@babel/preset-typescript": "^7.13.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
"@storybook/addon-actions": "^6.5.9",
"@storybook/addon-essentials": "^6.5.9",
"@storybook/addon-interactions": "^6.5.9",
"@storybook/addon-links": "^6.5.9",
"@storybook/builder-webpack4": "^6.5.9",
"@storybook/builder-webpack5": "^6.5.9",
"@storybook/manager-webpack4": "^6.5.9",
"@storybook/manager-webpack5": "^6.5.9",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^6.5.9",
"@storybook/testing-library": "^0.0.13",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@tsconfig/node14": "^1.0.0",
"@types/express": "^4.17.8",
"@types/jest": "^26.0.20",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.34",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.16",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"awesome-typescript-loader": "^5.2.1",
"babel-eslint": "^10.0.0",
"babel-loader": "^8.2.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"body-parser": "^1.19.0",
"buffer": "^6.0.3",
"bulma": "^0.9.3",
"clean-webpack-plugin": "^1.0.0",
"comlink": "^4.3.0",
"concurrently": "^4.0.0",
"copy-webpack-plugin": "^9.0.1",
"css-loader": "^5.1.2",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"express": "^4.17.1",
"file-loader": "^6.2.0",
"fs-extra": "^9.1.0",
"html-webpack-plugin": "^5.3.1",
"http-response-stream": "^1.0.9",
"image-webpack-loader": "^8.1.0",
"@graphql-codegen/cli": "^6.1.2",
"@graphql-codegen/typescript": "^5.0.8",
"@graphql-codegen/typescript-operations": "^5.0.8",
"@graphql-codegen/typescript-react-query": "^6.1.2",
"@iconify-json/solar": "^1.2.5",
"@iconify/json": "^2.2.443",
"@iconify/tailwind": "^1.2.0",
"@iconify/tailwind4": "^1.2.1",
"@iconify/utils": "^3.1.0",
"@storybook/addon-essentials": "^8.6.17",
"@storybook/addon-interactions": "^8.6.17",
"@storybook/addon-links": "^8.6.17",
"@storybook/addon-onboarding": "^8.6.17",
"@storybook/blocks": "^8.6.17",
"@storybook/react": "^8.6.17",
"@storybook/react-vite": "^8.6.17",
"@storybook/testing-library": "^0.2.2",
"@tailwindcss/postcss": "^4.2.1",
"@tanstack/eslint-plugin-query": "^5.91.4",
"@tanstack/react-query-devtools": "^5.91.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/ellipsize": "^0.1.3",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"autoprefixer": "^10.4.27",
"docdash": "^2.0.2",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^62.7.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-storybook": "^0.11.1",
"identity-obj-proxy": "^3.0.0",
"install": "^0.13.0",
"jest": "^26.6.3",
"mini-css-extract-plugin": "^1.4.1",
"node-sass": "^7.0.1",
"nodemon": "^1.17.3",
"npm": "^8.11.0",
"prettier": "^2.2.1",
"react-refresh": "^0.14.0",
"rimraf": "^3.0.2",
"sass-loader": "^11.0.1",
"source-map-loader": "^0.2.4",
"string-similarity": "^4.0.4",
"style-loader": "^2.0.0",
"tslint": "^6.1.3",
"typescript": "^4.2.3",
"url-loader": "^1.0.1",
"webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.7.4",
"webpack-merge": "^5.7.3"
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"prettier": "^3.8.1",
"react-refresh": "^0.18.0",
"rimraf": "^6.1.3",
"sass": "^1.97.3",
"storybook": "^8.6.17",
"tailwindcss": "^4.2.1",
"ts-jest": "^29.4.6",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^6.0.2",
"wait-on": "^9.0.4"
},
"resolutions": {
"@storybook/react/webpack": "^5"
"jackspeak": "2.1.1"
}
}

7
postcss.config.js Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Three Two!</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

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

@@ -7,11 +7,12 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
It is based on React 18, and uses:
1. _Redux_ for state management
1. _zustand_ for state management
2. _socket.io_ for transferring data in real-time
3. _React Router_ for routing
4. React DnD for drag-and-drop
5. @tanstack/react-table for all tables
6. @tanstack/react-query for API calls

View File

@@ -33,9 +33,9 @@ interface SearchData {
priority: PriorityEnum;
}
function sleep(ms: number): Promise<NodeJS.Timeout> {
export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
return new Promise((resolve) => setTimeout(resolve, ms));
}
};
export const toggleAirDCPPSocketConnectionStatus =
(status: String, payload?: any) => async (dispatch) => {
@@ -55,87 +55,9 @@ export const toggleAirDCPPSocketConnectionStatus =
break;
default:
console.log("Can't set AirDC++ socket status.");
break;
}
};
export const search =
(data: SearchData, ADCPPSocket: any, credentials: any) =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
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
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
dispatch({
type: AIRDCPP_SEARCH_RESULTS_ADDED,
groupedResult,
});
},
instance.id,
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
dispatch({
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
groupedResult,
});
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
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;
}
};
export const downloadAirDCPPItem =
(
searchInstanceId: Number,

View File

@@ -1,10 +1,6 @@
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import {
AxiosCacheRequestConfig,
createCacheAdapter,
} from "axios-simple-cache-adapter";
import qs from "qs";
import { setupCache } from "axios-cache-interceptor";
import {
CV_SEARCH_SUCCESS,
CV_API_CALL_IN_PROGRESS,
@@ -26,32 +22,28 @@ import {
LIBRARY_SERVICE_BASE_URI,
} from "../constants/endpoints";
const axiosCacheAdapter = createCacheAdapter();
const http = rateLimiter(axios.create(), {
maxRequests: 1,
perMilliseconds: 1000,
maxRPS: 1,
});
const cachedAxios = setupCache(axios);
export const getWeeklyPullList = (options) => async (dispatch) => {
try {
dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
});
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: options,
axiosCacheAdapter,
cache: 1000, // value in MS
} as AxiosCacheRequestConfig).then((response) => {
}).then((response) => {
dispatch({
type: CV_WEEKLY_PULLLIST_FETCHED,
data: response.data.result,
});
});
} catch (error) {
console.log(error);
// Error handling could be added here if needed
}
};
@@ -70,9 +62,6 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: "repeat" });
},
});
switch (options.callURIAction) {
@@ -84,10 +73,9 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
break;
default:
console.log("Could not complete request.");
break;
}
} catch (error) {
console.log(error);
dispatch({
type: CV_API_GENERIC_FAILURE,
error,
@@ -110,7 +98,6 @@ export const getIssuesForSeries =
comicObjectID,
},
});
console.log(issues);
dispatch({
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
issues: issues.data.results,

View File

@@ -4,13 +4,12 @@ import {
COMICVINE_SERVICE_URI,
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
SEARCH_SERVICE_BASE_URI,
JOB_QUEUE_SERVICE_BASE_URI,
} from "../constants/endpoints";
import {
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED,
CV_API_CALL_IN_PROGRESS,
@@ -22,23 +21,37 @@ import {
LS_IMPORT,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS,
LS_TOGGLE_IMPORT_QUEUE,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
WANTED_COMICS_FETCHED,
VOLUMES_FETCHED,
CV_WEEKLY_PULLLIST_FETCHED,
LIBRARY_SERVICE_HEALTH,
LS_SET_QUEUE_STATUS,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { success } from "react-notification-system-redux";
import { isNil, map } from "lodash";
import { isNil } from "lodash";
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
axios
.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
method: "GET",
transformResponse: (r: string) => JSON.parse(r),
})
.then((response) => {
const { data } = response;
dispatch({
type: LIBRARY_SERVICE_HEALTH,
status: data,
});
});
};
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
return axios
.request<Array<IFolderData>>({
@@ -74,19 +87,38 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
// autoDismiss: 0,
// }),
// );
const sessionId = localStorage.getItem("sessionId");
dispatch({
type: LS_IMPORT,
meta: { remote: true },
data: {},
});
await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
method: "POST",
data: { sessionId },
});
};
export const toggleImportQueueStatus = (options) => async (dispatch) => {
export const getImportJobResultStatistics = () => async (dispatch) => {
const result = await axios.request({
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
method: "GET",
});
dispatch({
type: LS_TOGGLE_IMPORT_QUEUE,
meta: { remote: true },
data: { manjhul: "jigyadam", action: options.action },
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
data: result.data,
});
};
export const setQueueControl =
(queueAction: string, queueStatus: string) => async (dispatch) => {
dispatch({
type: LS_SET_QUEUE_STATUS,
meta: { remote: true },
data: { queueAction, queueStatus },
});
};
/**
* Fetches comic book metadata for various types
* @return metadata for the comic book object categories
@@ -118,7 +150,7 @@ export const getComicBooks = (options) => async (dispatch) => {
});
break;
default:
console.log("Unrecognized comic status.");
break;
}
};
@@ -127,7 +159,8 @@ export const getComicBooks = (options) => async (dispatch) => {
* @returns Nothing.
* @param payload
*/
export const importToDB = (sourceName: string, metadata?: any) => (dispatch) => {
export const importToDB =
(sourceName: string, metadata?: any) => (dispatch) => {
try {
const comicBookMetadata = {
importType: "new",
@@ -144,7 +177,7 @@ export const importToDB = (sourceName: string, metadata?: any) => (dispatch) =>
},
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
}
},
};
dispatch({
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
@@ -169,7 +202,7 @@ export const importToDB = (sourceName: string, metadata?: any) => (dispatch) =>
importError: error,
});
}
};
};
export const fetchVolumeGroups = () => async (dispatch) => {
try {
@@ -185,12 +218,11 @@ export const fetchVolumeGroups = () => async (dispatch) => {
data: response.data,
});
} catch (error) {
console.log(error);
// Error handling could be added here if needed
}
};
export const fetchComicVineMatches =
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
console.log(issueSearchQuery);
try {
dispatch({
type: CV_API_CALL_IN_PROGRESS,
@@ -239,7 +271,7 @@ export const fetchComicVineMatches =
});
});
} catch (error) {
console.log(error);
// Error handling could be added here if needed
}
dispatch({
@@ -254,7 +286,6 @@ export const fetchComicVineMatches =
* @returns {any}
*/
export const extractComicArchive =
(path: string, options: any): any =>
async (dispatch) => {
dispatch({
@@ -301,26 +332,26 @@ export const searchIssue = (query, options) => async (dispatch) => {
case "wantedComicsPage":
dispatch({
type: WANTED_COMICS_FETCHED,
data: response.data.body,
data: response.data.hits,
});
break;
case "globalSearchBar":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
data: response.data.body,
data: response.data.hits,
});
break;
case "libraryPage":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED,
data: response.data.body,
data: response.data.hits,
});
break;
case "volumesPage":
dispatch({
type: VOLUMES_FETCHED,
data: response.data.body,
data: response.data.hits,
});
break;

View File

@@ -7,8 +7,6 @@ export const fetchMetronResource = async (options) => {
`${METRON_SERVICE_URI}/fetchResource`,
options,
);
console.log(metronResourceResults);
console.log("has more? ", !isNil(metronResourceResults.data.next));
const results = metronResourceResults.data.results.map((result) => {
return {
label: result.name || result.__str__,

View File

@@ -3,25 +3,14 @@ import {
SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
} from "../constants/action-types";
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} from "../reducers/settings.reducer";
import {
LIBRARY_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} from "../constants/endpoints";
export const saveSettings =
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
method: "POST",
data: { settingsPayload, settingsObjectId },
});
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: result.data,
});
};
export const getSettings = (settingsKey?) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
@@ -67,3 +56,22 @@ export const flushDb = () => async (dispatch) => {
});
}
};
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
method: "POST",
data: hostInfo,
});
const qBittorrentClientInfo = await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
method: "GET",
});
dispatch({
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
data: qBittorrentClientInfo.data,
});
};
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};

View File

@@ -1,635 +0,0 @@
@import "../../../../node_modules/bulma/bulma.sass";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "../../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
@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;
$issue-color: #f2f1f9;
$size-8: 0.9rem;
$size-9: 0.7rem;
$flexSize: 4em;
$boxSpacing: 1em;
$colorText: #404646;
.is-size-8 {
font-size: $size-8;
}
.is-size-9 {
font-size: $size-9;
}
.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,96 +0,0 @@
import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form";
import { useDispatch } from "react-redux";
import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select";
import { saveSettings } from "../../actions/settings.actions";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
const dispatch = useDispatch();
const [hubList, setHubList] = useState([]);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { settings, socket },
} = airDCPPConfiguration;
useEffect(() => {
(async () => {
if (!isEmpty(settings)) {
const hubs = await socket.get(`hubs`);
const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
setHubList(hubSelectionOptions);
}
})();
}, []);
const onSubmit = (values) => {
if (!isUndefined(values.hubs)) {
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
}
};
const validate = async () => {};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<div>
<h3 className="title">Hubs</h3>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against.
</h6>
</div>
<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">
Submit
</button>
</form>
)}
/>
<div className="mt-4">
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article>
</div>
<div className="box mt-3">
<h6>Selected hubs</h6>
{settings.directConnect.client.hubs.map(({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
))}
</div>
</>
);
};
export default AirDCPPHubsForm;

View File

@@ -1,35 +0,0 @@
import React, { ReactElement } from "react";
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
const { settings } = settingsObject;
console.log(settings);
return (
<div className="mt-4 is-clearfix">
<div className="card">
<div className="card-content">
<span className="tag is-pulled-right is-primary">Connected</span>
<div className="content is-size-7">
<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>Active Sessions: {settings.user.active_sessions}</dt>
<dt>
Permissions:{" "}
<pre>
{JSON.stringify(settings.user.permissions, undefined, 2)}
</pre>
</dt>
</dl>
</div>
</div>
</div>
</div>
);
};
export default AirDCPPSettingsConfirmation;

View File

@@ -1,154 +0,0 @@
import React, { ReactElement, useCallback, useContext } from "react";
import { Form, Field } from "react-final-form";
import { useDispatch } from "react-redux";
import { saveSettings, deleteSettings } from "../../actions/settings.actions";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { isUndefined, isEmpty, isNil } from "lodash";
export const AirDCPPSettingsForm = (): ReactElement => {
const dispatch = useDispatch();
const airDCPPSettings = useContext(AirDCPPSocketContext);
const hostValidator = (hostname: string): string | null => {
const hostnameRegex = /[\W]+/gm;
try {
if (!isUndefined(hostname)) {
const matches = hostname.match(hostnameRegex);
return (isNil(matches) && matches.length !== 0) ? hostname : "Invalid hostname; it should not contain special characters";
}
}
catch {
return null;
}
}
const onSubmit = useCallback(async (values) => {
try {
airDCPPSettings.setSettings(values);
dispatch(
saveSettings({
host: values,
}),
);
} catch (error) {
console.log(error);
}
}, []);
const removeSettings = useCallback(async () => {
airDCPPSettings.setSettings({});
dispatch(deleteSettings());
}, []);
const validate = async () => { };
const initFormData = !isUndefined(
airDCPPSettings.airDCPPState.settings.directConnect,
)
? airDCPPSettings.airDCPPState.settings.directConnect.client.host
: {};
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
initialValues={initFormData}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<h2>AirDC++ Connection Information</h2>
<label className="label">AirDC++ Hostname</label>
<div className="field has-addons">
<p className="control">
<span className="select">
<Field name="protocol" component="select">
<option>Protocol</option>
<option value="http">http://</option>
<option value="https">https://</option>
</Field>
</span>
</p>
<div className="control is-expanded">
<Field
name="hostname"
validate={hostValidator}>
{({ input, meta }) => (
<div>
<input {...input} type="text" placeholder="AirDC++ hostname" className="input" />
{meta.error && meta.touched && <span className="is-size-7 has-text-danger">{meta.error}</span>}
</div>
)}
</Field>
</div>
<p className="control">
<Field
name="port"
component="input"
className="input"
placeholder="AirDC++ port"
/>
</p>
</div>
<div className="field">
<div className="is-clearfix">
<label className="label">Credentials</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="username"
component="input"
className="input"
placeholder="Username"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
<div className="field">
<p className="control is-expanded has-icons-left has-icons-right">
<Field
name="password"
component="input"
type="password"
className="input"
placeholder="Password"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-lock"></i>
</span>
<span className="icon is-small is-right">
<i className="fas fa-check"></i>
</span>
</p>
</div>
</div>
</div>
<div className="field is-grouped">
<p className="control">
<button type="submit" className="button is-primary">
{!isEmpty(initFormData) ? "Update" : "Save"}
</button>
</p>
</div>
</form>
)}
/>
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
<AirDCPPSettingsConfirmation
settings={airDCPPSettings.airDCPPState.socketConnectionInformation}
/>
) : null}
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
<p className="control mt-4">
<button className="button is-danger" onClick={removeSettings}>
Delete
</button>
</p>
) : null}
</>
);
};
export default AirDCPPSettingsForm;

View File

@@ -1,139 +1,21 @@
import React, { ReactElement, useContext, useEffect } from "react";
import Dashboard from "./Dashboard/Dashboard";
import React, { ReactElement, useEffect } from "react";
import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
import "../assets/scss/App.css";
import { useStore } from "../store";
import Import from "./Import";
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
import LibraryGrid from "./Library/LibraryGrid";
import Search from "./Search";
import Settings from "./Settings";
import VolumeDetail from "./VolumeDetail/VolumeDetail";
import Downloads from "./Downloads/Downloads";
import { Routes, Route } from "react-router-dom";
import Navbar from "./Navbar";
import "../assets/scss/App.scss";
import {
AirDCPPSocketContextProvider,
AirDCPPSocketContext,
} from "../context/AirDCPPSocket";
import { isEmpty, isUndefined } from "lodash";
import {
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
LS_SINGLE_IMPORT,
} from "../constants/action-types";
import { useDispatch, useSelector } from "react-redux";
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
const AirDCPPSocketComponent = (): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
useEffect(() => {
const initializeAirDCPPEventListeners = async () => {
if (
!isUndefined(airDCPPConfiguration.airDCPPState) &&
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
) {
await airDCPPConfiguration.airDCPPState.socket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
// download tick listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
dispatch({
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
downloadProgressData,
});
},
);
// download complete listener
await airDCPPConfiguration.airDCPPState.socket.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.`);
dispatch({
type: LS_SINGLE_IMPORT,
meta: { remote: true },
data: bundleData,
});
count += 1;
}
}
},
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle changes",
);
console.log(
"[AirDCPP]: Listener registered - listening to transfer completion",
);
}
};
initializeAirDCPPEventListeners();
}, [airDCPPConfiguration]);
return <></>;
};
export const App = (): ReactElement => {
useEffect(() => {
useStore.getState().getSocket("/"); // Connect to the base namespace
}, []);
return (
<AirDCPPSocketContextProvider>
<div>
<AirDCPPSocketComponent />
<Navbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/import" element={<Import path={"./comics"} />} />
<Route
path="/library"
element={<TabulatedContentContainer category="library" />}
/>
<Route path="/library-grid" element={<LibraryGrid />} />
<Route path="/downloads" element={<Downloads data={{}} />} />
<Route path="/search" element={<Search />} />
<Route
path={"/comic/details/:comicObjectId"}
element={<ComicDetailContainer />}
/>
<Route
path={"/volume/details/:comicObjectId"}
element={<VolumeDetail />}
/>
<Route path="/settings" element={<Settings />} />
<Route
path="/pull-list/all"
element={<TabulatedContentContainer category="pullList" />}
/>
<Route
path="/wanted/all"
element={<TabulatedContentContainer category="wanted" />}
/>
<Route
path="/volumes/all"
element={<TabulatedContentContainer category="volumes" />}
/>
</Routes>
</div>
</AirDCPPSocketContextProvider>
<>
<Navbar2 />
<Outlet />
<ToastContainer stacked hideProgressBar />
</>
);
};

View File

@@ -1,70 +0,0 @@
import * as React from "react";
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../shared/utils/formatting.utils";
import { isUndefined, isEmpty, isNil } from "lodash";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
import ellipsize from "ellipsize";
interface IProps {
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
mongoObjId?: number;
hasTitle: boolean;
title?: string;
isHorizontal: boolean;
}
interface IState {}
class Card extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
}
public drawCoverCard = (
metadata: IExtractedComicBookCoverFile,
): JSX.Element => {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div>
<div className="card generic-card">
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
<div className="card-image">
<figure className="image">
<img src={filePath} alt="Placeholder image" />
</figure>
</div>
{this.props.hasTitle && (
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
)}
</div>
</div>
</div>
);
};
public render() {
return (
<>
{!isUndefined(this.props.comicBookCoversMetadata) &&
!isEmpty(this.props.comicBookCoversMetadata) &&
this.drawCoverCard(this.props.comicBookCoversMetadata)}
</>
);
}
}
export default Card;

View File

@@ -1,92 +0,0 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { isEmpty, isNil } from "lodash";
interface ICardProps {
orientation: string;
imageUrl: string;
hasDetails: boolean;
title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike;
borderColorClass?: string;
backgroundColor?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: PropTypes.object;
imageStyle?: PropTypes.object;
}
const renderCard = (props): ReactElement => {
switch (props.orientation) {
case "horizontal":
return (
<div className="card-container">
<div className="card generic-card">
<div className="is-horizontal">
<div className="card-image">
<img
style={props.imageStyle}
src={props.imageUrl}
alt="Placeholder image"
className="cropped-image"
/>
</div>
{props.hasDetails && (
<div className="card-content">{props.children}</div>
)}
</div>
</div>
</div>
);
case "vertical":
return (
<div onClick={props.onClick}>
<div className="generic-card" style={props.cardContainerStyle}>
<div
className={
!isNil(props.borderColorClass)
? `${props.borderColorClass}`
: ""
}
>
<div
className={
props.hasDetails
? "partial-rounded-card-image"
: "rounded-card-image"
}
>
<figure>
<img
src={props.imageUrl}
style={props.imageStyle}
alt="Placeholder image"
/>
</figure>
</div>
{props.hasDetails && (
<div
className="card-content"
style={{ backgroundColor: props.backgroundColor }}
>
{!isNil(props.title) ? (
<div className="card-title is-size-8 is-family-secondary">
{props.title}
</div>
) : null}
{props.children}
</div>
)}
</div>
</div>
</div>
);
default:
return <></>;
}
};
export const Card = (props: ICardProps): ReactElement => {
return renderCard(props);
};
export default Card;

View File

@@ -1,21 +1,22 @@
import React, {
useCallback,
useContext,
ReactElement,
useEffect,
useRef,
useState,
} from "react";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
import type { Socket } from "socket.io-client";
interface IAcquisitionPanelProps {
query: any;
@@ -27,253 +28,331 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const socketRef = useRef<Socket>();
const queryClient = useQueryClient();
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
const { comicObjectId } = props;
const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Selectors for picking state
const airDCPPSearchResults = useSelector((state: RootState) => {
return state.airdcpp.searchResults;
useEffect(() => {
const socket = useStore.getState().getSocket("manual");
socketRef.current = socket;
// --- Handlers ---
const handleResultAdded = ({ result }: any) => {
setAirDCPPSearchResults((prev) =>
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
);
};
const handleResultUpdated = ({ result }: any) => {
setAirDCPPSearchResults((prev) => {
const idx = prev.findIndex((r) => r.id === result.id);
if (idx === -1) return prev;
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
const next = [...prev];
next[idx] = result;
return next;
});
const 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);
const handleSearchInitiated = (data: any) => {
setAirDCPPSearchInstance(data.instance);
};
const dispatch = useDispatch();
const [dcppQuery, setDcppQuery] = useState({});
const handleSearchesSent = (data: any) => {
setAirDCPPSearchInfo(data.searchInfo);
};
// --- Subscribe once ---
socket.on("searchResultAdded", handleResultAdded);
socket.on("searchResultUpdated", handleResultUpdated);
socket.on("searchInitiated", handleSearchInitiated);
socket.on("searchesSent", handleSearchesSent);
return () => {
socket.off("searchResultAdded", handleResultAdded);
socket.off("searchResultUpdated", handleResultUpdated);
socket.off("searchInitiated", handleSearchInitiated);
socket.off("searchesSent", handleSearchesSent);
// if you want to fully close the socket:
// useStore.getState().disconnectSocket("/manual");
};
}, []);
const {
data: settings,
isLoading,
isError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
useEffect(() => {
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
hub_urls: map(hubs?.data, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}
}, [airDCPPConfiguration]);
}, [hubs, sanitizedIssueName]);
const getDCPPSearchResults = useCallback(
async (searchQuery) => {
const search = async (searchData: any) => {
setAirDCPPSearchResults([]);
socketRef.current?.emit("call", "socket.search", {
query: searchData,
namespace: "/manual",
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
});
};
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
config: any,
): Promise<void> => {
socketRef.current?.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => {
// Download initiated
},
);
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
hub_urls: [hubs?.data[0].hub_url],
priority: 5,
};
dispatch(
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
}),
);
},
[dispatch, airDCPPConfiguration],
);
// 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}`,
},
),
);
},
[airDCPPConfiguration],
);
search(manualQuery);
};
return (
<>
<div className="comic-detail columns">
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
<div className="mt-5 mb-3">
{!isEmpty(hubs?.data) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form
onSubmit={handleSubmit}
className="column is-three-quarters"
>
<div className="box search">
<div className="columns">
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="column is-two-thirds">
<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">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<input
{...input}
className="input main-search-bar is-medium"
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"
/>
<span className="help is-clearfix is-light is-info">
Use this to perform a manual search.
</span>
<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 DC++
<div className="h-5 w-5 ml-2">
<img
src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</div>
</button>
</div>
</div>
);
}}
</Field>
<div className="column">
<button
type="submit"
className={
isAirDCPPSearchInProgress
? "button is-loading is-warning"
: "button"
}
>
<span className="icon is-small">
<img src="/img/airdcpp_logo.svg" />
</span>
<span className="airdcpp-text">Search on AirDC++</span>
</button>
</div>
</div>
</div>
</form>
)}
/>
) : (
<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
role="alert"
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"
>
No AirDC++ hub configured. Please configure it in{" "}
<code>Settings &gt; AirDC++ &gt; Hubs</code>.
</article>
</div>
)}
</div>
{/* configured hub */}
{!isEmpty(hubs?.data) && (
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
<span className="pr-1 pt-1">
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
</span>
{hubs && hubs?.data[0].hub_url}
</span>
)}
{/* AirDC++ search instance details */}
{!isNil(searchInfo) && !isNil(searchInstance) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
{!isNil(airDCPPSearchInstance) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<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="tags mb-1">
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
({ value }) => (
<span className="tag is-warning" key={value}>
{value}
<div className="mb-1">
{hubs?.data.map((value, idx: string) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
),
)}
))}
</div>
</dt>
<dt>
Query:{" "}
Query:
<span className="has-text-weight-semibold">
{searchInfo.query.pattern}
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<dd>File type: {searchInfo.query.file_type}</dd>
<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>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<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: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="columns">
<div className="">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="column">
<table className="table">
<div className="overflow-x-auto max-w-full mt-6">
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Slots</th>
<th>Actions</th>
<tr className="border-b border-gray-300 dark:border-slate-700">
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
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">
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>
</thead>
<tbody>
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
{map(
airDCPPSearchResults,
({ dupe, type, name, id, slots, users, size }, idx) => (
<tr
key={idx}
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">
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}{" "}
{ellipsize(result.name, 70)}
{type.id === "directory" && (
<i className="fas fa-folder mr-1"></i>
)}
{ellipsize(name, 45)}
</p>
<dl>
<dd>
<div className="tags">
{!isNil(result.dupe) ? (
<span className="tag is-warning">Dupe</span>
) : null}
<span className="tag is-light is-info">
{result.users.user.nicks}
<div className="inline-flex flex-wrap gap-1">
{!isNil(dupe) && (
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
Dupe
</span>
{result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}>
)}
<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>
{users.user.flags.map((flag, idx) => (
<span
key={idx}
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}
</span>
))}
@@ -281,52 +360,77 @@ export const AcquisitionPanel = (
</dd>
</dl>
</td>
<td>
<span className="tag is-light is-info">
{result.type.id === "directory"
? "directory"
: result.type.str}
{/* TYPE */}
<td className="px-2 py-3">
<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--zip-file-bold-duotone] w-4 h-4"></i>
{type.str}
</span>
</td>
<td>
<div className="tags has-addons">
<span className="tag is-success">
{result.slots.free} free
{/* SLOTS */}
<td className="px-2 py-3">
<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--settings-minimalistic-bold-duotone] w-4 h-4"></i>
{slots.total} slots; {slots.free} free
</span>
<span className="tag is-light">
{result.slots.total}
</span>
</div>
</td>
<td>
<a
{/* ACTIONS */}
<td className="px-2 py-3">
<button
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
onClick={() =>
downloadDCPPResult(
searchInstance.id,
result.id,
result.name,
result.size,
result.type,
download(
airDCPPSearchInstance.id,
id,
comicObjectId,
name,
size,
type,
{
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
)
}
>
<i className="fas fa-file-download"></i>
</a>
Download
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
</button>
</td>
</tr>
);
})}
),
)}
</tbody>
</table>
</div>
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
<div className="">
<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>
<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>
Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from <code>ADCS</code> hubs are
more reliable than <code>NMDCS</code> ones.
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>
</article>
</div>

View File

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

View File

@@ -1,45 +1,70 @@
import React, { useState, ReactElement, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import React, { useState, ReactElement, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import Card from "../Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import Card from "../shared/Carda";
import { RawFileDetails } from "./RawFileDetails";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import TabControls from "./TabControls";
import { EditMetadataPanel } from "./EditMetadataPanel";
import { Menu } from "./ActionMenu/Menu";
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
import AcquisitionPanel from "./AcquisitionPanel";
import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select";
import "react-sliding-pane/dist/react-sliding-pane.css";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import Loader from "react-loader-spinner";
import SlidingPane from "react-sliding-pane";
import Modal from "react-modal";
import ComicViewer from "react-comic-viewer";
import { extractComicArchive } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { styled } from "styled-components";
import type { RawFileDetails as RawFileDetailsType, InferredMetadata } from "../../graphql/generated";
// Extracted modules
import { useComicVineMatching } from "./useComicVineMatching";
import { createTabConfig } from "./tabConfig";
import { actionOptions, customStyles, ActionOption } from "./actionMenuConfig";
import { CVMatchesPanel, EditMetadataPanelWrapper } from "./SlidingPanelContent";
// Styled component - moved outside to prevent recreation
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
interface ComicVineMetadata {
name?: string;
volumeInformation?: Record<string, unknown>;
[key: string]: unknown;
}
interface Acquisition {
directconnect?: {
downloads?: unknown[];
};
torrent?: unknown[];
[key: string]: unknown;
}
interface ComicDetailProps {
data: {
_id: string;
rawFileDetails?: RawFileDetailsType;
inferredMetadata: InferredMetadata;
sourcedMetadata: {
comicvine?: ComicVineMetadata;
locg?: Record<string, unknown>;
comicInfo?: Record<string, unknown>;
};
acquisition?: Acquisition;
createdAt: string;
updatedAt: string;
};
userSettings?: Record<string, unknown>;
queryClient?: unknown;
comicObjectId?: string;
}
type ComicDetailProps = {};
/**
* Component for displaying the metadata for a comic in greater detail.
* Displays full comic detail: cover, file info, action menu, and tabbed panels
* for metadata, archive operations, and acquisition.
*
* @component
* @example
* return (
* <ComicDetail/>
* )
* @param data.queryClient - react-query client passed through to the CV match
* panel so it can invalidate queries after a match is applied.
* @param data.comicObjectId - optional override for the comic ID; used when the
* component is rendered outside a route that provides the ID via `useParams`.
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
data: {
@@ -47,110 +72,67 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
rawFileDetails,
inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
},
userSettings,
queryClient,
comicObjectId: comicObjectIdProp,
} = data;
const [page, setPage] = useState(1);
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
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 { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
const dispatch = useDispatch();
const openModal = useCallback((filePath) => {
setIsOpen(true);
dispatch(
extractComicArchive(filePath, {
type: "full",
purpose: "reading",
imageResizeOptions: {
baseWidth: 1024,
},
}),
);
}, []);
const afterOpenModal = useCallback((things) => {
// references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
console.log("kolaveri", things);
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// sliding panel init
const contentForSlidingPanel = {
CVMatches: {
content: (props) => (
<>
<div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
</div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
{inferredMetadata.issue ? (
<div className="ml-3">
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
<span className="tag"> # {inferredMetadata.issue.number} </span>
</div>
) : null}
{!comicVineAPICallProgress ? (
<ComicVineMatchPanel
props={{
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
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: {
content: () => <EditMetadataPanel />,
},
// Action event handlers
const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
// check for the availability of CV metadata
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Hide "match on Comic Vine" when there are no raw file details — matching
// requires file metadata to seed the search query.
const Placeholder = components.Placeholder;
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action: ActionOption) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
break;
}
};
// Check for metadata availability
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
const hasAnyMetadata =
isComicBookMetadataAvailable ||
!isEmpty(comicInfo) ||
!isNil(locg);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
const { issueName, url } = determineCoverFile({
rawFileDetails,
@@ -158,158 +140,114 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
locg,
});
// query for airdc++
const airDCPPQuery = {
issue: {
name: issueName,
},
// Query for airdc++
const airDCPPQuery = useMemo(() => ({
issue: { name: issueName },
}), [issueName]);
// Create tab configuration
const openReconcilePanel = useCallback(() => {
setSlidingPanelContentId("metadataReconciliation");
setVisible(true);
}, []);
const tabGroup = useMemo(() => createTabConfig({
data: data.data,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId: _id,
userSettings,
issueName,
acquisition,
onReconcileMetadata: openReconcilePanel,
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
// Sliding panel content mapping
const renderSlidingPanelContent = () => {
switch (slidingPanelContentId) {
case "CVMatches":
return (
<CVMatchesPanel
rawFileDetails={rawFileDetails}
inferredMetadata={inferredMetadata}
comicVineMatches={comicVineMatches}
// Prefer the route param; fall back to the data ID when rendered outside a route.
comicObjectId={comicObjectId || _id}
queryClient={queryClient}
onMatchApplied={() => {
setVisible(false);
setActiveTab(1);
}}
/>
);
case "editComicBookMetadata":
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
default:
return null;
}
};
// Tab content and header details
const tabGroup = [
{
id: 1,
name: "Volume Information",
icon: <i className="fa-solid fa-layer-group"></i>,
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>,
content: (
<div className="columns" key={2}>
<div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
name: "Archive Operations",
content: <ArchiveOperations data={data.data} key={3} />,
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: <i className="fa-solid fa-floppy-disk"></i>,
name: "Acquisition",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
) : (
"Downloads"
),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
),
shouldShow: true,
},
];
// filtered Tabs
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
// Determine which cover image to use:
// 1. from the locally imported or
// 2. from the CV-scraped version
return (
<section className="container">
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<div className="section">
{!isNil(data) && !isEmpty(data) && (
<>
<h1 className="title">{issueName}</h1>
<div className="columns is-multiline">
<div className="column is-narrow">
<div>
<div className="flex flex-row mt-5">
<Card
imageUrl={url}
orientation={"vertical"}
orientation={"cover-only"}
hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }}
/>
{/* raw file details */}
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails?.cover) && (
<div className="grid">
<RawFileDetails
data={{
rawFileDetails,
inferredMetadata,
createdAt,
}}
>
{/* action dropdown */}
<div className="mt-4 is-size-7">
<div className="mt-1 flex flex-row gap-2 w-full">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div>
</div>
{/* raw file details */}
<div className="column">
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && (
<>
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
configuration={{
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
</div>
</RawFileDetails>
</div>
)}
</div>
</div>
<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;"}}
<TabControls
filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</Modal>
</>
)}
</div>
</div>
{<TabControls filteredTabs={filteredTabs} />}
<SlidingPane
<StyledSlidingPanel
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
{renderSlidingPanelContent()}
</StyledSlidingPanel>
</>
)}
</div>

View File

@@ -1,22 +1,40 @@
import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import React, { ReactElement } from "react";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQueryClient } from "@tanstack/react-query";
import { useGetComicByIdQuery } from "../../graphql/generated";
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
export const ComicDetailContainer = (): ReactElement | null => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const dispatch = useDispatch();
const { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
const queryClient = useQueryClient();
const {
data: comicBookDetailData,
isLoading,
isError,
} = useGetComicByIdQuery(
{ id: comicObjectId! },
{ enabled: !!comicObjectId }
);
if (isError) {
return <div>Error loading comic details</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
const adaptedData = comicBookDetailData?.comic
? adaptGraphQLComicToLegacy(comicBookDetailData.comic)
: null;
return adaptedData ? (
<ComicDetail
data={adaptedData}
queryClient={queryClient}
comicObjectId={comicObjectId}
/>
) : null;
};

View File

@@ -1,119 +1,118 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs";
import { isUndefined } from "lodash";
import Card from "../Carda";
export const ComicVineDetails = (props): ReactElement => {
import { isEmpty, isUndefined } from "lodash";
import Card from "../shared/Carda";
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;
return (
<div className="column is-half">
<div className="comic-detail comicvine-metadata">
<dl>
<dt>ComicVine Metadata</dt>
<dd className="is-size-7">
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</dd>
<dd>
<div className="columns mt-2">
<div className="column is-2">
<div className="text-slate-500 dark:text-gray-400">
<div className="">
<div>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical"}
orientation={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column is-10">
<dl>
<dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
</dt>
<dd>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</dd>
</div>
</div>
<dd>
Published by
<span className="has-text-weight-semibold">
{" "}
{data.volumeInformation.publisher.name}
</span>
</dd>
<dd>
Total issues in this volume:
{/* 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}
</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>
</span>
</div>
<div>
{data.issue_number && (
<div className="">
<span>Issue Number</span>
<span>{data.issue_number}</span>
</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">
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(
data.volumeInformation.description,
).displayName
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>
<span>Type</span>
<span>{data.resource_type}</span>
</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>
</dd>
</dl>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</div>
</div>
</dd>
</dl>
</div>
</div>
);
};
export default ComicVineDetails;
ComicVineDetails.propTypes = {
updatedAt: PropTypes.string,
data: PropTypes.shape({
name: PropTypes.string,
number: PropTypes.string,
resource_type: PropTypes.string,
id: PropTypes.number,
}),
};

View File

@@ -1,23 +1,53 @@
import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "../MatchResult";
import MatchResult from "./MatchResult";
import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const {
comicObjectId,
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
} = comicVineData.props;
interface ComicVineMatchPanelProps {
props: {
comicObjectId: string;
comicVineMatches: any[];
queryClient?: any;
onMatchApplied?: () => void;
};
}
/** Displays ComicVine search results or a status message while searching. */
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
const { comicvine } = useStore(
useShallow((state) => ({
comicvine: state.comicvine,
})),
);
return (
<>
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<div>
{!isEmpty(comicVineMatches) ? (
<MatchResult
matchData={comicVineSearchResults}
matchData={comicVineMatches}
comicObjectId={comicObjectId}
queryClient={queryClient}
onMatchApplied={onMatchApplied}
/>
) : (
<>
<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>
</>

View File

@@ -0,0 +1,96 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../../actions/fileops.actions";
/**
* Component for performing search against ComicVine
*
* @component
* @example
* return (
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = (data) => {
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
number: value.issueNumber,
subtitle: "",
year: value.issueYear,
},
};
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
};
const MyForm = () => (
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="flex items-center">
<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>
<label className="block py-1">Issue Name</label>
<Field name="issueName">
{(props) => (
<input
{...props.input}
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"
placeholder="Type the issue name"
/>
)}
</Field>
<div className="flex flex-row gap-4">
<div>
<label className="block py-1">Number</label>
<Field name="issueNumber">
{(props) => (
<input
{...props.input}
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>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
</div>
<div className="flex justify-end mt-5">
<button
type="submit"
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"
>
Search
</button>
</div>
</div>
</form>
)}
/>
);
return <MyForm />;
};
export default ComicVineSearchForm;

View File

@@ -1,33 +1,127 @@
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
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 (
<div >
<h4 className="is-size-6">{props.data.name}</h4>
<div>
<span className="is-size-3 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "}
</span>
<progress
className="progress is-small is-success"
value={props.data.downloaded_bytes}
max={props.data.size}
>
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
100}
%
</progress>
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
{/* Downloaded vs Total */}
<div className="mt-1 flex items-center space-x-2">
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
</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 className="is-size-5">
Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)}
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
{/* Speed and Time Left */}
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
<span>Speed: {speed}</span>
<span>Time left: {minutesLeft} min</span>
</div>
<div>{props.data.target}</div>
</div>
);
};

View File

@@ -1,108 +1,154 @@
import React, { useEffect, useContext, ReactElement } from "react";
import React, { useEffect, ReactElement, useState, useMemo } from "react";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles";
import { TorrentDownloads } from "./TorrentDownloads";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
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 {
data: any;
comicObjectId: string;
export interface TorrentDetails {
infoHash: string;
progress: number;
downloadSpeed?: number;
uploadSpeed?: number;
}
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
const bundles = useSelector((state: RootState) => {
return state.airdcpp.bundles;
});
/**
* DownloadsPanel displays two tabs of download information for a specific comic:
* - DC++ (AirDCPP) bundles
* - Torrent downloads
* It also listens for real-time torrent updates via a WebSocket.
*
* @component
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
*/
export const DownloadsPanel = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
"directconnect",
);
// AirDCPP Socket initialization
const userSettings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { socketIOInstance } = useStore(
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
/**
* Registers socket listeners on mount and cleans up on unmount.
*/
useEffect(() => {
try {
if (!isEmpty(userSettings)) {
dispatch(
getBundlesForComic(props.comicObjectId, socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
}
} catch (error) {
throw new Error(error);
}
}, [dispatch, airDCPPConfiguration]);
if (!socketIOInstance) return;
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
/**
* Handler for incoming torrent data events.
* Merges new entries or updates existing ones by infoHash.
*
* @param {TorrentDetails} data - Payload from the socket event.
*/
const handleTorrentData = (data: TorrentDetails) => {
setTorrentDetails((prev) => {
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
if (idx === -1) {
return [...prev, data];
}
const next = [...prev];
next[idx] = { ...next[idx], ...data };
return next;
});
};
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">
{!isEmpty(socket) ? (
<Bundles data={bundles} />
<div className="mt-5 mb-3">
<nav className="flex space-x-2">
<button
onClick={() => setActiveTab("directconnect")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "directconnect"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
DC++
</button>
<button
onClick={() => setActiveTab("torrents")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "torrents"
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
Torrents
</button>
</nav>
<div className="mt-4">
{activeTab === "torrents" ? (
<TorrentDownloads data={torrentDetails} />
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
<AirDCPPBundles data={bundles.data} />
) : (
<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>
<p>No DC++ bundles found.</p>
)}
</div>
</div>
</>
) : null;
);
};
export default DownloadsPanel;

View File

@@ -1,55 +1,41 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Form, Field } from "react-final-form";
import React, { ReactElement } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
const onSubmit = async () => {};
interface EditMetadataPanelProps {
data: {
name?: string | null;
[key: string]: any;
};
}
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const rawFileDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
);
const dispatch = useDispatch();
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Sliding panel form for manually editing comic metadata fields. */
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
const onSubmit = async () => {};
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
mutators={{
...arrayMutators,
}}
mutators={{ ...arrayMutators }}
render={({
handleSubmit,
form: {
mutators: { push, pop },
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
},
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
@@ -58,93 +44,59 @@ export const EditMetadataPanel = (props): ReactElement => {
<label className="label">Issue Details</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="issue_name"
component="input"
className="input"
initialValue={rawFileDetails}
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"
initialValue={data.name}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
</div>
</div>
{/* Issue Number and year */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<div className="mt-4 flex flex-row gap-2">
<div>
<div className="text-sm">Issue Number</div>
<Field
name="issue_number"
component="input"
className="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="Issue Number"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
<p className="text-xs">Do not enter the first zero</p>
</div>
{/* year */}
<div className="field">
<p className="control">
<div>
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
component="input"
className="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"
/>
</p>
</div>
</div>
</div>
{/* 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">
<div>
<div className="text-sm">Page Count</div>
<Field
name="page_count"
component="input"
className="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"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-note-sticky"></i>
</span>
</p>
</div>
</div>
</div>
{/* Description */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Description</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<div className="mt-2">
<label className="text-sm">Description</label>
<Field
name={"description"}
className="textarea"
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"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
</div>
<hr size="1" />
<hr />
<div className="field is-horizontal">
<div className="field-label">
@@ -184,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* Publisher */}
<div className="field is-horizontal">
@@ -255,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div>
</div>
<hr size="1" />
<hr />
{/* team credits */}
<div className="field is-horizontal">
@@ -333,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>

View File

@@ -0,0 +1,159 @@
import React from "react";
import { isNil, map } from "lodash";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
import { useGetComicByIdQuery } from "../../graphql/generated";
interface MatchResultProps {
matchData: any;
comicObjectId: string;
queryClient?: any;
onMatchApplied?: () => void;
}
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
};
export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (match, comicObjectId) => {
try {
const response = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
method: "POST",
data: {
match,
comicObjectId,
},
});
// Invalidate and refetch the comic book metadata
if (props.queryClient) {
await props.queryClient.invalidateQueries({
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
});
}
// Call the callback to close panel and switch tabs
if (props.onMatchApplied) {
props.onMatchApplied();
}
return response;
} catch (error) {
console.error("Error applying ComicVine match:", error);
throw error;
}
};
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) => {
let issueDescription = "";
if (!isNil(match.description)) {
issueDescription = convert(match.description, {
baseElements: {
selectors: ["p"],
},
});
}
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<img
className="rounded-md"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
{/* score */}
<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">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.score, 10)}
</span>
</span>
</div>
<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)}
</div>
</div>
</div>
<div className="flex flex-row gap-2 my-4 ml-10">
<div className="">
<img
src={match.volumeInformation.results.image.icon_url}
className="rounded-md w-full"
onError={handleBrokenImage}
/>
</div>
<div className="">
<span>{match.volume.name}</span>
<div className="text-sm">
<p>
Total Issues:
{match.volumeInformation.results.count_of_issues}
</p>
<p>
Published by{" "}
{match.volumeInformation.results.publisher.name}
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<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={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="text-md">Apply Match</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
</span>
</button>
</div>
</div>
);
})}
</>
);
};
export default MatchResult;

View File

@@ -1,97 +1,116 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import React, { ReactElement, ReactNode } from "react";
import prettyBytes from "pretty-bytes";
import { isUndefined } from "lodash";
import { isEmpty } from "lodash";
import { format, parseISO, isValid } from "date-fns";
import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata } = props.data;
type RawFileDetailsProps = {
data?: {
rawFileDetails?: RawFileDetailsType;
inferredMetadata?: InferredMetadata;
createdAt?: string;
};
children?: ReactNode;
};
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
return (
<>
<div className="comic-detail raw-file-details column is-three-fifths">
<dl>
<dt>Raw File Details</dt>
<dd className="is-size-7">
{rawFileDetails.containedIn +
"/" +
rawFileDetails.name +
rawFileDetails.extension}
<div className="max-w-2xl ml-5">
<div className="px-4 sm:px-6">
<p className="text-gray-500 dark:text-gray-400">
<span className="text-xl">{rawFileDetails?.name}</span>
</p>
</div>
<div className="px-4 py-5 sm:px-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Raw File Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{rawFileDetails?.containedIn}
{"/"}
{rawFileDetails?.name}
{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>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Inferred Issue Metadata
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
Series Name: {inferredMetadata?.issue?.name}
{!isEmpty(inferredMetadata?.issue?.number) ? (
<span className="tag is-primary is-light">
{rawFileDetails.extension}
{inferredMetadata?.issue?.number}
</span>
</div>
</div>
</div>
</dd>
</dl>
</div>
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
<dl>
{/* inferred metadata */}
<dt>Inferred Issue Metadata</dt>
<dd>
<div className="field is-grouped mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Name</span>
<span className="tag is-info is-light">
{inferredMetadata.issue.name}
</span>
</div>
</div>
{!isUndefined(inferredMetadata.issue.number) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary is-light">
{inferredMetadata.issue.number}
</span>
</div>
</div>
) : null}
</div>
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
MIMEType
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.mimeType}
</span>
</span>
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
File Size
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
</span>
</span>
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{createdAt && isValid(parseISO(createdAt)) ? (
<>
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(createdAt), "h aaaa")}
</>
) : "N/A"}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Actions
</dt>
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
</div>
</dl>
</div>
</div>
</>
);
};
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,
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

@@ -0,0 +1,65 @@
import React from "react";
import { ComicVineSearchForm } from "./ComicVineSearchForm";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { EditMetadataPanel } from "./EditMetadataPanel";
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
interface CVMatchesPanelProps {
rawFileDetails?: RawFileDetails;
inferredMetadata: InferredMetadata;
comicVineMatches: any[];
comicObjectId: string;
queryClient: any;
onMatchApplied: () => void;
};
/**
* Sliding panel content for ComicVine match search.
*
* Renders a search form pre-populated from `rawFileDetails`, a preview of the
* inferred issue being searched for, and a list of ComicVine match candidates
* the user can apply to the comic.
*
* @param props.onMatchApplied - Called after the user selects and applies a match,
* allowing the parent to close the panel and refresh state.
*/
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
rawFileDetails,
inferredMetadata,
comicVineMatches,
comicObjectId,
queryClient,
onMatchApplied,
}) => (
<>
<div>
<ComicVineSearchForm data={rawFileDetails} />
</div>
<div className="border-slate-500 border rounded-lg p-2 mt-3">
<p className="">Searching for:</p>
{inferredMetadata.issue ? (
<>
<span className="">{inferredMetadata.issue?.name} </span>
<span className=""> # {inferredMetadata.issue?.number} </span>
</>
) : null}
</div>
<ComicVineMatchPanel
props={{
comicVineMatches,
comicObjectId,
queryClient,
onMatchApplied,
}}
/>
</>
);
type EditMetadataPanelWrapperProps = {
rawFileDetails?: RawFileDetails;
};
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
rawFileDetails,
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;

View File

@@ -1,50 +1,59 @@
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { useSelector } from "react-redux";
import React, { ReactElement, Suspense, useState } from "react";
import { isNil } from "lodash";
export const TabControls = (props): ReactElement => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const { filteredTabs } = props;
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
setActive(filteredTabs[0].id);
}, [comicBookDetailData]);
// Use controlled state if provided, otherwise use internal state
const currentActive = activeTab !== undefined ? activeTab : active;
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
return (
<>
<div className="tabs">
<ul>
<div className="hidden sm:block mt-7 mb-3 w-fit">
<div className="border-b border-gray-200">
<nav className="flex gap-6" aria-label="Tabs">
{filteredTabs.map(({ id, name, icon }) => (
<li
<a
key={id}
className={id === active ? "is-active" : ""}
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 ${
currentActive === id
? "border-b border-cyan-50 dark:text-slate-200"
: "border-b border-transparent"
}`}
aria-current="page"
onClick={() => handleSetActive(id)}
>
{/* Downloads tab and count badge */}
<a>
{id === 5 &&
!isNil(comicBookDetailData.acquisition.directconnect) ? (
<span className="download-icon-labels">
<i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light">
{comicBookDetailData.acquisition.directconnect.downloads.length}
<>
{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 className="icon is-small">{icon}</span>
<span className="w-5 h-5">{icon}</span>
)}
{name}
</>
</a>
</li>
))}
</ul>
</nav>
</div>
{filteredTabs.map(({ id, content }) => {
return active === id ? content : null;
})}
</div>
<Suspense fallback={null}>
{filteredTabs.map(({ id, content }) => (
<React.Fragment key={id}>
{currentActive === id ? content : null}
</React.Fragment>
))}
</Suspense>
</>
);
};

View File

@@ -1,59 +1,157 @@
import React, { ReactElement, useCallback, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { DnD } from "../../DnD";
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import {
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../../constants/endpoints";
import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow";
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
export const ArchiveOperations = (props): ReactElement => {
export const ArchiveOperations = (props: { data: any }): ReactElement => {
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 getSocket = useStore((state) => state.getSocket);
const queryClient = useQueryClient();
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
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("/");
if (!socket) return;
const handleUncompressionComplete = (data: any) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
};
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
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);
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => {
dispatch(
extractComicArchive(data.rawFileDetails.filePath, {
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
}
},
});
} catch (error) {
// Error handling could be added here if needed
}
};
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,
});
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
const [currentImage, setCurrentImage] = useState([]);
useEffect(() => {
if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
}
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
// sliding panel init
const contentForSlidingPanel = {
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
imageAnalysis: {
content: () => {
return (
<div>
<pre className="is-size-7">{currentImage}</pre>
<pre className="text-sm">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="is-size-7 p-2 mt-3">
<pre className="p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
<pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
</pre>
</div>
);
@@ -62,56 +160,83 @@ export const ArchiveOperations = (props): ReactElement => {
};
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => {
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
setSlidingPanelContentId("imageAnalysis");
dispatch(analyzeImage(imageFilePath));
analyzeImage(imageFilePath);
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<button
className={
isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
<article
role="alert"
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"
>
<span className="icon is-small">
<i className="fa-solid fa-box-open"></i>
</span>
<span>Unpack comic archive</span>
</button>
<div className="columns">
<div>
<p>You can perform several operations on your comic book archive.</p>
<p>
Uncompressing, re-organizing the individual pages, then
re-compressing to a different format, for example.
</p>
<p>You can also analyze color histograms of pages.</p>
</div>
</article>
<div className="mt-5">
{!isEmpty(extractedComicBookArchive) ? (
{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
data={extractedComicBookArchive}
data={uncompressedArchive}
onClickHandler={openImageAnalysisPanel}
/>
) : null}
</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>
<SlidingPane
isOpen={visible}

View File

@@ -1,54 +1,67 @@
import { isUndefined } from "lodash";
import React, { ReactElement } from "react";
export const ComicInfoXML = (data): ReactElement => {
export const ComicInfoXML = (data: { json: any }): ReactElement => {
const { json } = data;
return (
<div className="comicInfo-metadata">
<dl className="has-text-size-7">
<dd className="has-text-weight-medium">{json.series[0]}</dd>
<dd className="mt-2 mb-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.publisher[0]}
<div className="flex w-3/4">
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
<dt>
<p className="text-lg">{json.series?.[0]}</p>
</dt>
<dd className="text-sm">
published by{" "}
<span className="underline">
{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>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Issue #</span>
<span className="tag is-warning is-light">
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.pagecount[0]}
</span>
</span>
</div>
</dd>
)}
{/* Genre */}
{!isUndefined(json.genre) && (
<div className="control">
<span className="tags has-addons">
<span className="tag">Genre</span>
<span className="tag is-success is-light">
<dd className="my-2">
<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--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-slate-500 dark:text-slate-900">
{json.genre[0]}
</span>
</span>
</div>
</dd>
)}
</span>
<dd className="my-1">
{/* Summary */}
{!isUndefined(json.summary) && (
<span className="text-md text-slate-500 dark:text-slate-900">
{json.summary[0]}
</span>
)}
</div>
</dd>
{!isUndefined(json.notes) && (
<dd>
<span className="is-size-7">{json.notes[0]}</span>
{/* Notes */}
<span className="text-sm text-slate-500 dark:text-slate-900">
{json.notes[0]}
</span>
</dd>
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
)}
</dl>
</div>
);

View File

@@ -1,30 +1,165 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useMemo, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { Drawer } from "vaul";
import ComicVineDetails from "../ComicVineDetails";
import { convert } from "html-to-text";
import { isEmpty } from "lodash";
export const VolumeInformation = (props): ReactElement => {
interface ComicVineMetadata {
volumeInformation?: Record<string, unknown>;
name?: string;
number?: string;
resource_type?: string;
id?: number;
}
interface SourcedMetadata {
comicvine?: ComicVineMetadata;
locg?: Record<string, unknown>;
comicInfo?: unknown;
metron?: unknown;
gcd?: unknown;
[key: string]: unknown;
}
interface VolumeInformationData {
sourcedMetadata?: SourcedMetadata;
inferredMetadata?: { issue?: unknown };
updatedAt?: string;
}
interface VolumeInformationProps {
data: VolumeInformationData;
onReconcile?: () => void;
}
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
const SOURCED_METADATA_KEYS = [
"comicvine",
"locg",
"comicInfo",
"metron",
"gcd",
];
const SOURCE_LABELS: Record<string, string> = {
comicvine: "ComicVine",
locg: "League of Comic Geeks",
comicInfo: "ComicInfo.xml",
metron: "Metron",
gcd: "Grand Comics Database",
inferredMetadata: "Local File",
};
const SOURCE_ICONS: Record<string, string> = {
comicvine: "icon-[solar--database-bold]",
locg: "icon-[solar--users-group-rounded-outline]",
comicInfo: "icon-[solar--file-text-outline]",
metron: "icon-[solar--planet-outline]",
gcd: "icon-[solar--book-outline]",
inferredMetadata: "icon-[solar--folder-outline]",
};
const MetadataSourceChips = ({
sources,
}: {
sources: string[];
}): ReactElement => {
const [isSheetOpen, setSheetOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
<div className="flex flex-row items-center justify-between">
<span className="text-md text-slate-500 dark:text-slate-400">
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
{sources.length} metadata sources detected
</span>
</div>
<div className="flex flex-row flex-wrap gap-2">
{sources.map((source) => (
<span
key={source}
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
>
<i
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
/>
{SOURCE_LABELS[source] ?? source}
</span>
))}
</div>
</div>
<button
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => setSheetOpen(true)}
>
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
Reconcile sources
</button>
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
<div className="p-4">
{/* Reconciliation UI goes here */}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</>
);
};
/**
* Displays volume metadata for a comic.
*
* - When multiple sources are present, renders a chip bar listing each source
* with a "Reconcile sources" action to merge them.
* - When exactly one source is present and it is ComicVine, renders the full
* ComicVine detail panel directly.
*
* @param props.data - Comic data containing sourced and inferred metadata.
* @param props.onReconcile - Called when the user triggers source reconciliation.
*/
export const VolumeInformation = (
props: VolumeInformationProps,
): ReactElement => {
const { data } = props;
const createDescriptionMarkup = (html) => {
return { __html: html };
};
const presentSources = useMemo(() => {
const sources = SOURCED_METADATA_KEYS.filter((key) => {
const val = (data?.sourcedMetadata ?? {})[key];
if (isNil(val) || isEmpty(val)) return false;
// locg returns an object even when empty; require at least one non-null value
if (key === "locg")
return Object.values(val as Record<string, unknown>).some(
(v) => !isNil(v) && v !== "",
);
return true;
});
if (
!isNil(data?.inferredMetadata?.issue) &&
!isEmpty(data?.inferredMetadata?.issue)
) {
sources.push("inferredMetadata");
}
return sources;
}, [data?.sourcedMetadata, data?.inferredMetadata]);
return (
<div key={1}>
<div className="columns is-multiline">
{presentSources.length > 1 && (
<MetadataSourceChips sources={presentSources} />
)}
{presentSources.length === 1 &&
data.sourcedMetadata?.comicvine?.volumeInformation && (
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
<div className="column is-8">
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
convert(data.sourcedMetadata.comicvine.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,76 @@
import React from "react";
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
export const TorrentDownloads = (props) => {
const { data } = props;
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) => {
// Torrent added successfully
},
});
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

@@ -0,0 +1,65 @@
import React from "react";
import { StylesConfig } from "react-select";
export interface ActionOption {
value: string;
label: React.ReactElement;
}
export const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
</div>
<div>Match on ComicVine</div>
</span>
);
export const editLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
</div>
<div>Edit Metadata</div>
</span>
);
export const deleteLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
</div>
<div>Delete Comic</div>
</span>
);
export const actionOptions: ActionOption[] = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
export const customStyles: StylesConfig<ActionOption, false> = {
menu: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base: any) => ({
...base,
color: "black",
}),
option: (base: any, { isFocused }: 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)",
}),
};

View File

@@ -0,0 +1,114 @@
import React, { lazy } from "react";
import { isNil, isEmpty } from "lodash";
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
const DownloadsPanel = lazy(() => import("./DownloadsPanel"));
interface TabConfig {
id: number;
name: string;
icon: React.ReactElement;
content: React.ReactElement | null;
shouldShow: boolean;
}
interface TabConfigParams {
data: any;
hasAnyMetadata: boolean;
areRawFileDetailsAvailable: boolean;
airDCPPQuery: any;
comicObjectId: string;
userSettings: any;
issueName: string;
acquisition?: any;
onReconcileMetadata?: () => void;
}
export const createTabConfig = ({
data,
hasAnyMetadata,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId,
userSettings,
issueName,
acquisition,
onReconcileMetadata,
}: TabConfigParams): TabConfig[] => {
return [
{
id: 1,
name: "Volume Information",
icon: (
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: hasAnyMetadata ? (
<VolumeInformation data={data} onReconcile={onReconcileMetadata} />
) : null,
shouldShow: hasAnyMetadata,
},
{
id: 3,
icon: (
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "Archive Operations",
content: <ArchiveOperations data={data} />,
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: (
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "DC++ Search",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={comicObjectId}
comicObject={data}
settings={userSettings}
/>
),
shouldShow: true,
},
{
id: 5,
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",
content: <TorrentSearchPanel comicObjectId={comicObjectId} issueName={issueName} />,
shouldShow: true,
},
{
id: 6,
name: "Downloads",
icon: (
<>
{(acquisition?.directconnect?.downloads?.length || 0) +
(acquisition?.torrent?.length || 0)}
</>
),
content:
!isNil(data) && !isEmpty(data) ? (
<DownloadsPanel />
) : (
<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,
},
];
};

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import axios from "axios";
import { isNil, isUndefined, isEmpty } from "lodash";
import { refineQuery } from "filename-parser";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
type ComicVineMatch = {
score: number;
[key: string]: any;
};
type ComicVineSearchQuery = {
inferredIssueDetails: {
name: string;
[key: string]: any;
};
[key: string]: any;
};
type ComicVineMetadata = {
name?: string;
[key: string]: any;
};
export const useComicVineMatching = () => {
const [comicVineMatches, setComicVineMatches] = useState<ComicVineMatch[]>([]);
const fetchComicVineMatches = async (
searchPayload: any,
issueSearchQuery: ComicVineSearchQuery,
seriesSearchQuery: ComicVineSearchQuery,
) => {
try {
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
method: "POST",
data: {
format: "json",
// hack
query: issueSearchQuery.inferredIssueDetails.name
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim(),
limit: "100",
page: 1,
resources: "volume",
scorerConfiguration: {
searchParams: issueSearchQuery.inferredIssueDetails,
},
rawFileDetails: searchPayload,
},
transformResponse: (r) => {
const matches = JSON.parse(r);
return matches;
},
});
let matches: ComicVineMatch[] = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match: ComicVineMatch) => match);
}
const scoredMatches = matches.sort((a: ComicVineMatch, b: ComicVineMatch) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
// Error handling could be added here if needed
}
};
const prepareAndFetchMatches = (
rawFileDetails: RawFileDetailsType | undefined,
comicvine: ComicVineMetadata | undefined,
) => {
let seriesSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
let issueSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
if (!isUndefined(rawFileDetails) && rawFileDetails.name) {
issueSearchQuery = refineQuery(rawFileDetails.name) as ComicVineSearchQuery;
} else if (!isEmpty(comicvine) && comicvine?.name) {
issueSearchQuery = refineQuery(comicvine.name) as ComicVineSearchQuery;
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
};
return {
comicVineMatches,
prepareAndFetchMatches,
};
};

View File

@@ -1,126 +0,0 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../actions/fileops.actions";
import { useDispatch } from "react-redux";
/**
* Component for performing search against ComicVine
*
* @component
* @example
* return (
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = (data) => {
const dispatch = useDispatch();
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
number: value.issueNumber,
subtitle: "",
year: value.issueYear,
},
};
dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
};
const MyForm = () => (
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="field is-normal">
<label className="label mb-2 is-size-5">Search Manually</label>
</span>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueName">
{(props) => (
<p className="control is-expanded has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue name"
/>
<span className="icon is-small is-left">
<i className="fas fa-journal-whills"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueNumber">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div>
</div>
</form>
)}
/>
);
return <MyForm />;
};
export default ComicVineSearchForm;

View File

@@ -1,96 +1,72 @@
import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import ZeroState from "./ZeroState";
import React, { ReactElement } from "react";
import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList";
import {
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil } from "lodash";
useGetRecentComicsQuery,
useGetWantedComicsQuery,
useGetVolumeGroupsQuery,
useGetLibraryStatisticsQuery
} from "../../graphql/generated";
export const Dashboard = (): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchVolumeGroups());
dispatch(
getComicBooks({
// Use GraphQL for recent comics
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
{ limit: 5 },
{ refetchOnWindowFocus: false }
);
// Wanted Comics - using GraphQL
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery(
{
paginationOptions: {
page: 0,
page: 1,
limit: 5,
sort: { updatedAt: "-1" },
sort: '{"updatedAt": -1}'
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}),
);
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
predicate: '{"acquisition.source.wanted": true}'
},
predicate: { "acquisition.source.wanted": true },
comicStatus: "wanted",
}),
);
dispatch(getLibraryStatistics());
}, []);
const recentComics = useSelector(
(state: RootState) => state.fileOps.recentComics
);
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const volumeGroups = useSelector(
(state: RootState) => state.fileOps.comicVolumeGroups,
);
const libraryStatistics = useSelector(
(state: RootState) => state.comicInfo.libraryStatistics,
);
return (
<div className="container">
<section className="section">
<h1 className="title">Dashboard</h1>
{!isEmpty(recentComics) ? (
<>
{/* Pull List */}
<PullList issues={recentComics} />
{/* Stats */}
{!isEmpty(libraryStatistics) && (
<LibraryStatistics stats={libraryStatistics} />
)}
{/* Wanted comics */}
{!isEmpty(wantedComics) && (
<WantedComicsList comics={wantedComics} />
)}
{/* Recent imports */}
<RecentlyImported comicBookCovers={recentComics} />
{/* Volumes */}
{!isEmpty(volumeGroups) && (
<VolumeGroups volumeGroups={volumeGroups} />
)}
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
{
refetchOnWindowFocus: false,
retry: false
}
/>
)}
</section>
);
// Volume Groups - using GraphQL
const { data: volumeGroupsData, error: volumeGroupsError } = useGetVolumeGroupsQuery(
undefined,
{ refetchOnWindowFocus: false }
);
// Library Statistics - using GraphQL
const { data: statisticsData, error: statisticsError } = useGetLibraryStatisticsQuery(
undefined,
{
refetchOnWindowFocus: false,
retry: false
}
);
const recentComics = recentComicsData?.comics?.comics || [];
const wantedComics = !wantedComicsError ? (wantedComicsData?.getComicBooks?.docs || []) : [];
const volumeGroups = volumeGroupsData?.getComicBookGroups || [];
const statistics = !statisticsError ? statisticsData?.getLibraryStatistics : undefined;
return (
<>
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<PullList />
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
{/* Wanted comics */}
<WantedComicsList comics={wantedComics} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics} />}
{/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups} />
</div>
</>
);
};

View File

@@ -1,114 +1,105 @@
import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash";
import React, { ReactElement } from "react";
import Header from "../shared/Header";
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
comicDirectorySize: DirectorySize;
comicsMissingFiles: number;
};
/** Props for {@link LibraryStatistics}. */
interface LibraryStatisticsProps {
stats: Stats | null | undefined;
}
/**
* Displays a snapshot of library metrics: total comic files, tagging coverage,
* file-type breakdown, and the publisher with the most issues.
*
* Returns `null` when `stats` is absent or the statistics array is empty.
*/
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
if (!stats || !stats.totalDocuments) return null;
const facet = stats.statistics?.[0];
if (!facet) return null;
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
const topPublisher = publisherWithMostComicsInLibrary?.[0];
export const LibraryStatistics = (
props: ILibraryStatisticsProps,
): ReactElement => {
// const { stats } = props;
return (
<div className="mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
</h4>
<p className="subtitle is-7">A brief snapshot of your library.</p>
<div className="columns is-multiline">
<div className="column is-narrow is-two-quarter">
<dl className="box">
<dd className="is-size-4">
<span className="has-text-weight-bold">
{props.stats.totalDocuments}
</span>{" "}
files
<Header
headerContent="Your Library In Numbers"
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="mt-3 flex flex-row gap-5">
{/* Total records in database */}
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.totalDocuments} comics
</dd>
<dd className="is-size-4">
Library size
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)}
</span>
</div>
{/* Missing files */}
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
<dd className="text-3xl text-red-600 md:text-5xl">
{stats.comicsMissingFiles}
</dd>
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issues.length}
</span>{" "}
</div>
{/* Disk space consumed */}
{stats.comicDirectorySize.totalSizeInGB != null && (
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
</dd>
</div>
)}
{/* Tagging coverage */}
<div className="flex flex-col gap-4">
{issues && issues.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issues.length}</span>
tagged with ComicVine
</dd>
</div>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
ComicInfo.xml
</span>
</dd>
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
with ComicInfo.xml
</div>
)}
</dl>
</div>
<div className="p-3 column is-one-quarter">
<dl className="box">
<dd className="is-size-6">
<span className="has-text-weight-bold"></span> Issues
</dd>
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
<dd className="is-size-6">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].fileTypes) &&
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
return (
<span key={idx}>
<span className="has-text-weight-bold">
{fileType.data.length}
{/* File-type breakdown */}
{fileTypes && fileTypes.length > 0 && (
<div>
{fileTypes.map((ft) => (
<span
key={ft.id}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
>
{ft.data.length} {ft.id}
</span>
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
{fileType._id}
</span>
</span>
);
})}
</dd>
</dl>
))}
</div>
)}
{/* file types */}
<div className="p-3 column is-two-fifths">
{/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id
}
</span>
{/* Publisher with most issues */}
{topPublisher && (
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
<span>{topPublisher.id}</span>
{" has the most issues "}
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count
}
</span>
</dd>
)}
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
</dl>
<span>{topPublisher.count}</span>
</div>
)}
</div>
</div>
);

View File

@@ -1,162 +1,180 @@
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../Carda";
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 React, { ReactElement, useState } from "react";
import { map } from "lodash";
import Card from "../shared/Carda";
import Header from "../shared/Header";
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";
type PullListProps = {
issues: any;
};
import axios from "axios";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import useEmblaCarousel from "embla-carousel-react";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import { Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
import { LocgMetadata, useGetWeeklyPullListQuery } from "../../graphql/generated";
export const PullList = ({ issues }: PullListProps): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2022-12-25",
pageSize: "15",
currentPage: "1",
}),
interface PullListProps {
issues?: LocgMetadata[];
}
export const PullList = (): ReactElement => {
const queryClient = useQueryClient();
// 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);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
const {
data: pullListData,
refetch,
isSuccess,
isLoading,
isError,
} = useGetWeeklyPullListQuery({
input: {
startDate: inputValue,
pageSize: 15,
currentPage: 1,
},
});
// Transform the data to match the old structure
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined;
const { mutate: addToLibrary } = useMutation({
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => {
const comicBookMetadata = {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
],
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
},
};
return await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: comicBookMetadata,
});
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
const next = () => {
sliderRef.slickNext();
// sliderRef.slickNext();
};
const previous = () => {
sliderRef.slickPrev();
// sliderRef.slickPrev();
};
return (
<>
<div className="content">
<h4 className="title is-4">
<i className="fa-solid fa-splotch"></i> Discover
</h4>
<p className="subtitle is-7">
Pull List aggregated for the week from League Of Comic Geeks
</p>
<div className="field is-grouped">
<Header
headerContent="Discover"
subHeaderContent={
<span className="text-md">
Pull List aggregated for the week from{" "}
<span className="underline">
<a href="https://leagueofcomicgeeks.com">
League Of Comic Geeks
</a>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
}
iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/"
/>
<div className="flex flex-row gap-5 mb-3">
{/* select week */}
<div className="control">
<div className="select is-small">
<select>
<option>Select Week</option>
<option>With options</option>
</select>
<div className="flex flex-row gap-4 my-3">
<Form
onSubmit={() => {}}
render={({ handleSubmit }) => (
<form>
<div className="flex flex-col gap-2">
{/* week selection for pull list */}
<DatePickerDialog
inputValue={inputValue}
setter={setInputValue}
/>
{inputValue && (
<div className="text-sm">
Showing pull list for{" "}
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{inputValue}
</span>
</div>
)}
</div>
</form>
)}
/>
</div>
</div>
{/* See all pull list issues */}
<div className="control">
<Link to={"/pull-list/all/"}>
<button className="button is-small">View all issues</button>
</Link>
</div>
<div className="field has-addons">
<div className="control">
<button className="button is-rounded is-small" onClick={previous}>
<i className="fa-solid fa-caret-left"></i>
</button>
</div>
<div className="control">
<button className="button is-rounded is-small" onClick={next}>
<i className="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
</div>
</div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({issue}, idx) => {
<div className="w-lvw -mr-4 sm:-mr-6 lg:-mr-8">
{isSuccess && !isLoading && (
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(pullList?.data.result, (issue: LocgMetadata, idx: number) => {
return (
<Card
<div
key={idx}
orientation={"vertical"}
imageUrl={issue.cover}
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={issue.cover || undefined}
hasDetails
title={ellipsize(issue.name, 18)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
title={ellipsize(issue.name || 'Unknown', 25)}
>
<div className="content">
<div className="control">
<span className="tag">{issue.publisher}</span>
</div>
<div className="mt-2">
<div className="px-1">
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{issue.publisher || 'Unknown Publisher'}
</span>
<div className="flex flex-row justify-end">
<button
className="button is-small is-success is-outlined is-light"
onClick={() => addToLibrary("locg", issue)}
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({ sourceName: "locg", metadata: { 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>
</div>
</div>
</Card>
</div>
);
})}
</Slider>
</div>
</div>
)}
{isLoading && <div>Loading...</div>}
{isError && <div>An error occurred while retrieving the pull list.</div>}
</div>
</>
);
};

View File

@@ -1,146 +1,144 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import Card from "../shared/Carda";
import { Link } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetRecentComicsQuery } from "../../graphql/generated";
type RecentlyImportedProps = {
comicBookCovers: any;
comics: GetRecentComicsQuery['comics']['comics'];
};
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
export const RecentlyImported = (
{ comics }: RecentlyImportedProps,
): ReactElement => {
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return (
<>
<div className="content mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
</h4>
<p className="subtitle is-7">
Recent Library activity such as imports, tagging, etc.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comicBookCovers,
(
{
_id,
<div>
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{comics?.map((comic, idx) => {
const {
id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
acquisition: {
source: { name },
},
},
idx,
) => {
sourcedMetadata,
canonicalMetadata,
inferredMetadata,
importStatus,
} = comic;
// Parse sourced metadata (GraphQL returns as strings)
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
? JSON.parse(sourcedMetadata.comicvine)
: sourcedMetadata?.comicvine;
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
? JSON.parse(sourcedMetadata.comicInfo)
: sourcedMetadata?.comicInfo;
const locg = sourcedMetadata?.locg;
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(name, {
comicvine,
comicInfo,
locg,
});
const isComicBookMetadataAvailable =
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
return (
<React.Fragment key={_id}>
<Card
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : null}
<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]"
>
<div className="content is-flex is-flex-direction-row">
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="icon custom-icon is-small has-text-danger mr-2">
<img src="/img/missing-file.svg" />
<Card
orientation="vertical-2"
imageUrl={url}
title={inferredMetadata?.issue?.name}
hasDetails
cardState={cardState}
>
<div>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline]"></i>
</span>
)}
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<span className="icon custom-icon is-small has-text-danger">
<img
src="/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
<span className="text-md text-slate-900">
{inferredMetadata?.issue?.number}
</span>
)}
{/* ComicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img
src="/img/cvlogo.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
{/* 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 */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<div className="mt-1">
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-gray-500 dark:text-white-300"></i>
</div>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-full h-full object-contain"
/>
</span>
)}
</div>
</div>
</Card>
{/* metadata card */}
{!isNil(name) && (
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
<dd className="is-size-9">
<dl>
<span className="icon custom-icon">
<img src={`/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
</div>
);
},
)}
</Masonry>
</>
})}
</div>
</div>
</div>
</div>
);
};

View File

@@ -2,62 +2,83 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom";
import Masonry from "react-masonry-css";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetVolumeGroupsQuery } from "../../graphql/generated";
export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
type VolumeGroupsProps = {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
};
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement | null => {
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
if (!deduplicatedGroups || deduplicatedGroups.length === 0) return null;
const navigate = useNavigate();
const navigateToVolumes = (row) => {
const navigateToVolumes = (row: any) => {
navigate(`/volumes/all`);
};
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return (
<section className="volumes-container mt-4">
<div className="content">
<a className="mb-1" onClick={navigateToVolumes}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-layer-group"></i> Volumes
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
<div>
<Header
headerContent="Volumes"
subHeaderContent={<>Based on ComicVine Volume information</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/volumes"}
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(deduplicatedGroups, (data) => {
return (
<div className="stack" key={data._id}>
<img src={data.volumes.image.small_url} />
<div className="content">
<div className="stack-title is-size-8">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 18)}
<div
key={data.id}
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={data.volumes?.image?.small_url || undefined}
hasDetails
>
<div className="py-3">
<div className="text-sm">
<Link to={`/volume/details/${data.id}`}>
{ellipsize(data.volumes?.name || 'Unknown', 48)}
</Link>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag is-primary is-light">Issues</span>
<span className="tag">{data.volumes.count_of_issues}</span>
{/* 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 || 0} issues
</span>
</span>
</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>
);
})}
</Masonry>
</section>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,60 +1,63 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import Card from "../shared/Carda";
import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetWantedComicsQuery } from "../../graphql/generated";
type WantedComicsListProps = {
comics: any;
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
};
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
}: WantedComicsListProps): ReactElement | null => {
if (!comics || comics.length === 0) return null;
const navigate = useNavigate();
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
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"
>
<div>
<Header
headerContent="Wanted Comics"
subHeaderContent={<>Comics marked as wanted from various sources</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"}
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(
comics,
({
_id,
(comic) => {
const {
id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
sourcedMetadata,
} = comic;
// Parse sourced metadata (GraphQL returns as strings)
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
? JSON.parse(sourcedMetadata.comicvine)
: sourcedMetadata?.comicvine;
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
? JSON.parse(sourcedMetadata.comicInfo)
: sourcedMetadata?.comicInfo;
const locg = sourcedMetadata?.locg;
const isComicBookMetadataAvailable = !isUndefined(comicvine);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
@@ -62,53 +65,74 @@ export const WantedComicsList = ({
locg,
};
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const {
issueName,
url,
publisher = null,
} = determineCoverFile(consolidatedComicMetadata);
const titleElement = (
<Link to={"/comic/details/" + _id}>
<Link to={"/comic/details/" + id}>
{ellipsize(issueName, 20)}
<p>{publisher}</p>
</Link>
);
return (
<div
key={id}
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
key={_id}
orientation={"vertical"}
orientation={"vertical-2"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
cardState="wanted"
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/img/locglogo.svg" />
</span>
)}
<div className="pb-1">
<div className="flex flex-row gap-2">
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
!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.volumeInformation.description,
).displayName
detectIssueTypes(comicvine?.description)
?.displayName
}
</span>
</span>
</div>
) : null}
{/* Wanted comics - info not available in current GraphQL query */}
</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>
);
},
)}
</Masonry>
</>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -1,9 +1,6 @@
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
import React, { ReactElement, useEffect, useState } from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { isEmpty, isNil, isUndefined } from "lodash";
import { searchIssue } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel";
@@ -12,16 +9,18 @@ interface IDownloadsProps {
}
export const Downloads = (props: IDownloadsProps): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { settings, socket },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// const dispatch = useDispatch();
const airDCPPTransfers = useSelector(
(state: RootState) => state.airdcpp.transfers,
);
const issueBundles = useSelector((state: RootState) => state.airdcpp.issue_bundles);
// const airDCPPTransfers = useSelector(
// (state: RootState) => state.airdcpp.transfers,
// );
// const issueBundles = useSelector(
// (state: RootState) => state.airdcpp.issue_bundles,
// );
const [bundles, setBundles] = useState([]);
// Make the call to get all transfers from AirDC++
useEffect(() => {
@@ -38,27 +37,34 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
useEffect(() => {
if (!isUndefined(issueBundles)) {
const foo = issueBundles.data.map((bundle) => {
const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
const {
rawFileDetails,
inferredMetadata,
acquisition: {
directconnect: { downloads },
},
sourcedMetadata: { locg, comicvine },
} = bundle;
const { issueName, url } = determineCoverFile({
rawFileDetails, comicvine, locg,
rawFileDetails,
comicvine,
locg,
});
return { ...bundle, issueName, url };
});
return { ...bundle, issueName, url }
})
setBundles(foo);
}
}, [issueBundles]);
}, [issueBundles])
return !isNil(bundles) ?
<div className="container">
return !isNil(bundles) ? (
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<section className="section">
<h1 className="title">Downloads</h1>
<div className="columns">
<div className="column is-half">
{bundles.map(bundle => {
console.log(bundle);
return <>
{bundles.map((bundle, idx) => {
return (
<div key={idx}>
<MetadataPanel
data={bundle}
imageStyle={{ maxWidth: 80 }}
@@ -68,32 +74,48 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
maxWidth: 400,
padding: 0,
margin: "0 0 8px 0",
}} />
}}
/>
<table className="table is-size-7">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Bundle ID</th>
</tr>
{bundle.acquisition.directconnect.downloads.map((bundle) => {
return(<tr>
</thead>
<tbody>
{bundle.acquisition.directconnect.downloads.map(
(bundle, idx) => {
return (
<tr key={idx}>
<td>{bundle.name}</td>
<td>{bundle.size}</td>
<td>{bundle.type.str}</td>
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
</tr>)
})}
<td>
<span className="tag is-warning">
{bundle.bundleId}
</span>
</td>
</tr>
);
},
)}
</tbody>
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</>
</div>
);
})}
</div>
</div>
</section>
</div> : <div>asd</div>;
</div>
) : (
<div>There are no downloads.</div>
);
};
export default Downloads;

View File

@@ -1,7 +1,7 @@
import { debounce, isEmpty, map } from "lodash";
import React, { ReactElement, useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import Card from "../Carda";
import Card from "../shared/Carda";
import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel";
@@ -31,7 +31,7 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
from: 0,
},
type: "volumeName",
trigger: "globalSearchBar"
trigger: "globalSearchBar",
},
),
);
@@ -51,7 +51,6 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
<i className="fa-solid fa-magnifying-glass"></i>
</span>
</div>
{!isEmpty(searchResults) ? (
<div
className="columns box is-multiline"
@@ -62,9 +61,10 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
margin: "60px 0 0 350px",
}}
>
{map(searchResults, (result) => (
{map(searchResults, (result, idx) => (
<MetadataPanel
data={result}
key={idx}
imageStyle={{ maxWidth: 70 }}
titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }}

View File

@@ -1,133 +0,0 @@
import React, { ReactElement, useCallback, useContext, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
fetchComicBookMetadata,
toggleImportQueueStatus,
} from "../actions/fileops.actions";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import Loader from "react-loader-spinner";
interface IProps {
matches?: unknown;
fetchComicMetadata?: any;
path: string;
covers?: any;
}
/**
* Returns the average of two numbers.
*
* @remarks
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
*
* @param x - The first input number
* @param y - The second input number
* @returns The arithmetic mean of `x` and `y`
*
* @beta
*/
export const Import = (props: IProps): ReactElement => {
const dispatch = useDispatch();
const libraryQueueResults = useSelector(
(state: RootState) => state.fileOps.librarySearchResultCount,
);
const libraryQueueImportStatus = useSelector(
(state: RootState) => state.fileOps.IMSCallInProgress,
);
const [isImportQueuePaused, setImportQueueStatus] = useState(false);
const initiateImport = useCallback(() => {
if (typeof props.path !== "undefined") {
dispatch(fetchComicBookMetadata(props.path));
}
}, [dispatch]);
const toggleImport = useCallback(() => {
setImportQueueStatus(!isImportQueuePaused);
if (isImportQueuePaused === false) {
dispatch(toggleImportQueueStatus({ action: "resume" }));
} else if (isImportQueuePaused === true) {
dispatch(toggleImportQueueStatus({ action: "pause" }));
}
}, [isImportQueuePaused]);
const pauseIconText = (
<>
<i className="fa-solid fa-pause mr-2"></i> Pause Import
</>
);
const playIconText = (
<>
<i className="fa-solid fa-play mr-2"></i> Resume Import
</>
);
return (
<div className="container">
<section className="section is-small">
<h1 className="title">Import</h1>
<article className="message is-dark">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Import Only
</span>
will add comics identified from the mapped folder into the local
db.
</p>
<p>
<span className="tag is-medium is-info is-light">
Import and Tag
</span>
will scan the ComicVine, shortboxed APIs and import comics from
the mapped folder with the additional metadata.
</p>
</div>
</article>
<p className="buttons">
<button
className={
libraryQueueImportStatus
? "button is-loading is-medium"
: "button is-medium"
}
onClick={initiateImport}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Import Only</span>
</button>
<button className="button is-medium">
<span className="icon">
<i className="fas fa-tag"></i>
</span>
<span>Import and Tag</span>
</button>
</p>
<div className="columns is-multiline">
<div className="column is-one-fifth">
<div className="box control-palette">
<span className="is-size-2 has-text-weight-bold">
{JSON.stringify(libraryQueueResults, null, 2)}
</span>
</div>
<div className="is-half">
<div className="content">
<div className="control">
<button
className="button is-warning is-light"
onClick={toggleImport}
>
{!isImportQueuePaused ? pauseIconText : playIconText}
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
};
export default Import;

View File

@@ -0,0 +1,493 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { Import } from './Import';
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.MockedFunction<any>;
// Mock zustand store
const mockGetSocket = jest.fn();
const mockDisconnectSocket = jest.fn();
const mockSetStatus = jest.fn();
jest.mock('../../store', () => ({
useStore: jest.fn((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
),
}));
// Mock socket.io-client
const mockSocket = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
};
mockGetSocket.mockReturnValue(mockSocket);
// Helper function to create a wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Import Component - Numerical Indices', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should display numerical indices in the Past Imports table', async () => {
// Mock API response with 3 import sessions
const mockData = [
{
sessionId: 'session-1',
earliestTimestamp: '2024-01-01T10:00:00Z',
completedJobs: 5,
failedJobs: 0
},
{
sessionId: 'session-2',
earliestTimestamp: '2024-01-02T10:00:00Z',
completedJobs: 3,
failedJobs: 1
},
{
sessionId: 'session-3',
earliestTimestamp: '2024-01-03T10:00:00Z',
completedJobs: 8,
failedJobs: 2
},
];
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for the "Past Imports" heading to appear
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify that the "#" column header exists
expect(screen.getByText('#')).toBeInTheDocument();
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
const rows = screen.getAllByRole('row');
// Skip header row (index 0), check data rows
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
});
test('should display correct indices for larger datasets', async () => {
// Mock API response with 10 import sessions
const mockData = Array.from({ length: 10 }, (_, i) => ({
sessionId: `session-${i + 1}`,
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
completedJobs: i + 1,
failedJobs: 0,
}));
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify indices 1 through 10 are present in the first column
const rows = screen.getAllByRole('row');
// Skip header row (index 0)
for (let i = 1; i <= 10; i++) {
const row = rows[i];
const cells = row.querySelectorAll('td');
expect(cells[0]).toHaveTextContent(i.toString());
}
});
});
describe('Import Component - Button Visibility', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should show Start Import button when queue status is drained', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
// Verify Pause and Resume buttons are NOT visible
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
});
test('should show Start Import button when queue status is undefined', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
});
test('should hide Start Import button and show Pause button when queue is running', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 5,
failedJobCount: 1,
mostRecentImport: 'Comic #123',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Pause')).toBeInTheDocument();
});
// Verify Import Activity section is visible
expect(screen.getByText('Import Activity')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
});
test('should hide Start Import button and show Resume button when queue is paused', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'paused',
successfulJobCount: 3,
failedJobCount: 0,
mostRecentImport: 'Comic #456',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Resume')).toBeInTheDocument();
});
});
});
describe('Import Component - SessionId and Socket Reconnection', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
localStorage.clear();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
jest.useRealTimers();
});
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
// Setup: Set old sessionId in localStorage
localStorage.setItem('sessionId', 'old-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is cleared immediately
expect(localStorage.getItem('sessionId')).toBeNull();
// Verify disconnectSocket is called
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
// Fast-forward 100ms
await act(async () => {
jest.advanceTimersByTime(100);
});
// Verify getSocket is called after 100ms
await waitFor(() => {
expect(mockGetSocket).toHaveBeenCalledWith('/');
});
// Fast-forward another 500ms
await act(async () => {
jest.advanceTimersByTime(500);
});
// Verify initiateImport is called and status is set to running
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: null },
});
expect(mockSetStatus).toHaveBeenCalledWith('running');
});
});
test('should NOT clear sessionId when starting import with undefined status', async () => {
// Setup: Set existing sessionId in localStorage
localStorage.setItem('sessionId', 'existing-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is NOT cleared
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
// Verify disconnectSocket is NOT called
expect(mockDisconnectSocket).not.toHaveBeenCalled();
// Verify status is set to running immediately
expect(mockSetStatus).toHaveBeenCalledWith('running');
// Verify initiateImport is called immediately (no delay)
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: 'existing-session-id' },
});
});
});
});
describe('Import Component - Real-time Updates', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
});
// Get the event handler that was registered
const coverExtractedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_COVER_EXTRACTED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (coverExtractedHandler) {
coverExtractedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
// Get the event handler that was registered
const queueDrainedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (queueDrainedHandler) {
queueDrainedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should cleanup socket listeners on unmount', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Unmount the component
unmount();
// Verify that socket listeners are removed
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
});
export {};

View File

@@ -0,0 +1,308 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
})),
);
// Force re-import mutation - re-imports all files regardless of import status
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
mutationFn: async () => {
const sessionId = localStorage.getItem("sessionId") || "";
return await axios.request({
url: `http://localhost:3000/api/library/forceReImport`,
method: "POST",
data: { sessionId },
});
},
onSuccess: (response) => {
console.log("Force re-import initiated:", response.data);
importJobQueue.setStatus("running");
setImportError(null);
},
onError: (error: any) => {
console.error("Failed to start force re-import:", error);
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
},
});
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
const importSession = useImportSessionStatus();
const hasActiveSession = importSession.isActive;
const wasComplete = useRef(false);
// React to importSession.isComplete rather than socket events — more reliable
// since it's derived from the actual GraphQL state, not a raw socket event.
useEffect(() => {
if (importSession.isComplete && !wasComplete.current) {
wasComplete.current = true;
// Small delay so the backend has time to commit job result stats
setTimeout(() => {
// Invalidate the cache to force a fresh fetch of job result statistics
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
refetch();
}, 1500);
importJobQueue.setStatus("drained");
} else if (!importSession.isComplete) {
wasComplete.current = false;
}
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
// Listen to socket events to update Past Imports table in real-time
useEffect(() => {
const socket = getSocket("/");
const handleImportCompleted = () => {
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
const handleQueueDrained = () => {
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
};
}, [getSocket, queryClient]);
/**
* Handles force re-import - re-imports all files to fix indexing issues
*/
const handleForceReImport = async () => {
setImportError(null);
// Check for active session before starting using definitive status
if (hasActiveSession) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
);
return;
}
if (window.confirm(
"This will re-import ALL files in your library folder, even those already imported. " +
"This can help fix Elasticsearch indexing issues. Continue?"
)) {
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setTimeout(() => {
forceReImport();
}, 500);
}, 100);
} else {
forceReImport();
}
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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">
Import
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<article
role="alert"
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"
>
<div>
<p>
Importing will add comics identified from the mapped folder into
ThreeTwo's database.
</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>
{/* Import Statistics */}
<div className="my-6 max-w-screen-lg">
<RealTimeImportStats />
</div>
{/* Error Message */}
{importError && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Import Error
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
{importError}
</p>
</div>
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</span>
</button>
</div>
</div>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<div className="my-6 max-w-screen-lg">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleForceReImport}
disabled={isForceReImporting || hasActiveSession}
title="Re-import all files to fix Elasticsearch indexing issues"
>
<span className="text-md font-medium">
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
</span>
</button>
</div>
)}
{/* Import activity is now shown in the RealTimeImportStats component above */}
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
<div className="max-w-screen-lg">
<span className="flex items-center mt-6">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Past Imports
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead className="ltr:text-left rtl:text-right">
<tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
#
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Time Started
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Session Id
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Imported
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Failed
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
return (
<tr key={index}>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
{index + 1}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
? format(
new Date(parseInt(jobResult.earliestTimestamp)),
"EEEE, hh:mma, do LLLL y",
)
: "N/A"}
</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>
</div>
);
};
export default Import;

View File

@@ -0,0 +1,379 @@
import { ReactElement, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
useGetImportStatisticsQuery,
useGetWantedComicsQuery,
useStartIncrementalImportMutation,
} from "../../graphql/generated";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
/**
* Import statistics with card-based layout and progress bar.
* Three states: pre-import (idle), importing (active), and post-import (complete).
* Also surfaces missing files detected by the file watcher.
*/
export const RealTimeImportStats = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const [detectedFile, setDetectedFile] = useState<string | null>(null);
const [socketImport, setSocketImport] = useState<{
active: boolean;
completed: number;
total: number;
failed: number;
} | null>(null);
const queryClient = useQueryClient();
const { getSocket, disconnectSocket, importJobQueue } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
importJobQueue: state.importJobQueue,
})),
);
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false },
);
const stats = importStats?.getImportStatistics?.stats;
// File list for the detail panel — only fetched when there are missing files
const { data: missingComicsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 3, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{
refetchOnWindowFocus: false,
refetchInterval: false,
enabled: (stats?.missingFiles ?? 0) > 0,
},
);
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
const getMissingComicLabel = (comic: any): string => {
const series =
comic.canonicalMetadata?.series?.value ??
comic.inferredMetadata?.issue?.name;
const issueNum =
comic.canonicalMetadata?.issueNumber?.value ??
comic.inferredMetadata?.issue?.number;
if (series && issueNum) return `${series} #${issueNum}`;
if (series) return series;
return comic.rawFileDetails?.name ?? comic.id;
};
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } =
useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
setImportError(
error?.message || "Failed to start import. Please try again.",
);
},
});
const hasNewFiles = stats && stats.newFiles > 0;
const missingCount = stats?.missingFiles ?? 0;
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
useEffect(() => {
const socket = getSocket("/");
const handleStatsChange = () => {
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
};
const handleFileDetected = (payload: { filePath: string }) => {
handleStatsChange();
const name = payload.filePath.split("/").pop() ?? payload.filePath;
setDetectedFile(name);
setTimeout(() => setDetectedFile(null), 5000);
};
const handleImportStarted = () => {
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
};
const handleCoverExtracted = (payload: {
completedJobCount: number;
totalJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) => ({
active: true,
completed: payload.completedJobCount,
total: payload.totalJobCount,
failed: prev?.failed ?? 0,
}));
};
const handleCoverExtractionFailed = (payload: {
failedJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) =>
prev ? { ...prev, failed: payload.failedJobCount } : null,
);
};
const handleQueueDrained = () => {
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
handleStatsChange();
};
socket.on("LS_LIBRARY_STATS", handleStatsChange);
socket.on("LS_FILES_MISSING", handleStatsChange);
socket.on("LS_FILE_DETECTED", handleFileDetected);
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("LS_LIBRARY_STATS", handleStatsChange);
socket.off("LS_FILES_MISSING", handleStatsChange);
socket.off("LS_FILE_DETECTED", handleFileDetected);
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
};
}, [getSocket, queryClient]);
const handleStartImport = async () => {
setImportError(null);
if (importSession.isActive) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
);
return;
}
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setTimeout(() => {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}, 500);
}, 100);
} else {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}
};
if (isLoading || !stats) {
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
}
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`
: `Start Incremental Import (${stats.newFiles} new files)`;
// Determine what to show in each card based on current phase
const sessionStats = importSession.stats;
const hasSessionStats = importSession.isActive && sessionStats !== null;
const totalFiles = stats.totalLocalFiles;
const importedCount = stats.alreadyImported;
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
const showProgressBar = socketImport !== null;
const socketProgressPct =
socketImport && socketImport.total > 0
? Math.round((socketImport.completed / socketImport.total) * 100)
: 0;
const showFailedCard = hasSessionStats && failedCount > 0;
const showMissingCard = missingCount > 0;
return (
<div className="space-y-6">
{/* Error Message */}
{importError && (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Import Error
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
{importError}
</p>
</div>
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</button>
</div>
</div>
)}
{/* File detected toast */}
{detectedFile && (
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
New file detected: {detectedFile}
</p>
</div>
)}
{/* Start Import button — only when idle with new files */}
{hasNewFiles && !importSession.isActive && (
<button
onClick={handleStartImport}
disabled={isStartingImport}
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
>
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
</button>
)}
{/* Progress bar — shown while importing and once complete */}
{showProgressBar && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{socketImport!.active
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
: `${socketImport!.completed} / ${socketImport!.total} imported`}
</span>
<span className="font-semibold text-gray-900 dark:text-white">
{socketProgressPct}% complete
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
style={{ width: `${socketProgressPct}%` }}
>
{socketImport!.active && (
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
)}
</div>
</div>
</div>
)}
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Total files */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#6b7280" }}
>
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
<div className="text-sm text-gray-200 font-medium">in import folder</div>
</div>
{/* Imported */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#d8dab2" }}
>
<div className="text-4xl font-bold text-gray-800 mb-2">
{importedCount}
</div>
<div className="text-sm text-gray-700 font-medium">
{importSession.isActive ? "imported so far" : "imported in database"}
</div>
</div>
{/* Failed — only shown after a session with failures */}
{showFailedCard && (
<div className="rounded-lg p-6 text-center bg-red-500">
<div className="text-4xl font-bold text-white mb-2">
{failedCount}
</div>
<div className="text-sm text-red-100 font-medium">failed</div>
</div>
)}
{/* Missing files — shown when watcher detects moved/deleted files */}
{showMissingCard && (
<div className="rounded-lg p-6 text-center bg-card-missing">
<div className="text-4xl font-bold text-slate-700 mb-2">
{missingCount}
</div>
<div className="text-sm text-slate-800 font-medium">missing</div>
</div>
)}
</div>
{/* Missing files detail panel */}
{showMissingCard && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-800 dark:text-amber-300">
{missingCount} {missingCount === 1 ? "file" : "files"} missing
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
These files were previously imported but can no longer be found
on disk. Move them back to restore access.
</p>
{missingDocs.length > 0 && (
<ul className="mt-2 space-y-1">
{missingDocs.map((comic, i) => (
<li
key={i}
className="text-xs text-amber-700 dark:text-amber-400 truncate"
>
{getMissingComicLabel(comic)} is missing
</li>
))}
{missingCount > 3 && (
<li className="text-xs text-amber-600 dark:text-amber-500">
and {missingCount - 3} more.
</li>
)}
</ul>
)}
<Link
to="/library?filter=missingFiles"
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
>
<span className="underline">
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
View Missing Files In Library
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
</span>
</Link>
</div>
</div>
</div>
)}
</div>
);
};
export default RealTimeImportStats;

View File

@@ -1,149 +1,194 @@
import React, { useMemo, ReactElement, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import React, { useMemo, ReactElement, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, parseISO } from "date-fns";
import { useGetWantedComicsQuery } from "../../graphql/generated";
type FilterOption = "all" | "missingFiles";
interface SearchQuery {
query: Record<string, any>;
pagination: { size: number; from: number };
type: string;
trigger: string;
}
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: "all", label: "All Comics" },
{ value: "missingFiles", label: "Missing Files" },
];
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
* Library page component. Displays a paginated, searchable table of all comics
* in the collection, with an optional filter for comics with missing raw files.
*/
export const Library = (): ReactElement => {
const searchResults = useSelector(
(state: RootState) => state.fileOps.libraryComics,
);
const searchError = useSelector((state: RootState) => {
console.log(state);
return state.fileOps.librarySearchError;
});
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
const [searchParams] = useSearchParams();
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {},
},
{
pagination: {
size: 15,
from: 0,
},
pagination: { size: 25, from: 0 },
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
});
// programatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
const queryClient = useQueryClient();
/** Fetches a page of issues from the search API. */
const fetchIssues = async (q: SearchQuery) => {
const { pagination, query, type } = q;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: { query, pagination, type },
});
};
const ComicInfoXML = (value) => {
return value.data ? (
<div className="comicvine-metadata">
<dl>
<span className="tags has-addons is-size-7">
<span className="tag">Series</span>
<span className="tag is-warning is-light">
const { data, isPlaceholderData } = useQuery({
queryKey: ["comics", searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
enabled: activeFilter === "all",
});
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 25, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "missingFiles" },
);
const { data: missingIdsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 1000, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "all" },
);
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
const missingIdSet = useMemo(
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
[missingIdsData],
);
const searchResults = data?.data;
const navigate = useNavigate();
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
/** Triggers a search by volume name and resets pagination. */
const searchIssues = (e: any) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: { volumeName: e.search },
pagination: { size: 15, from: 0 },
type: "volumeName",
trigger: "libraryPage",
});
};
/** Advances to the next page of results. */
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from: pageSize * pageIndex + 1 },
type: "all",
trigger: "libraryPage",
});
}
};
/** Goes back to the previous page of results. */
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from },
type: "all",
trigger: "libraryPage",
});
};
const ComicInfoXML = (value: any) =>
value.data ? (
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
{ellipsize(value.data.series[0], 25)}
</span>
</span>
</dl>
<dl>
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Pages</span>
<span className="tag is-info is-light has-text-weight-bold">
{value.data.pagecount[0]}
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
Pages: {value.data.pagecount[0]}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Issue</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{!isNil(value.data.number) && (
<span className="tag has-text-weight-bold is-success is-light">
{parseInt(value.data.number[0], 10)}
</span>
<span>{parseInt(value.data.number[0], 10)}</span>
)}
</span>
</div>
</span>
</div>
</dl>
</div>
) : null;
};
const WantedStatus = ({ value }) => {
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
};
const columns = useMemo(
const missingFilesColumns = useMemo(
() => [
{
header: "Comic Metadata",
footer: 1,
header: "Missing Files",
columns: [
{
header: "File Details",
id: "fileDetails",
minWidth: 400,
accessorKey: "_source",
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
align: "center",
minWidth: 250,
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : (
<span className="tag mt-5">No ComicInfo.xml</span>
header: "Status",
id: "missingStatus",
cell: () => (
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
MISSING
</span>
</div>
),
},
],
},
{
header: "Additional Metadata",
columns: [
{
header: "Publisher",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (info) => {
return (
!isNil(info.getValue()) && (
<h6 className="is-size-7 has-text-weight-bold">
{info.getValue().publisher.name}
</h6>
)
);
},
},
{
header: "Something",
accessorKey: "_source.acquisition.source.wanted",
cell: (info) => {
!isUndefined(info.getValue()) ? (
<WantedStatus value={info.getValue().toString()} />
) : (
"Nothing"
);
},
header: "Comic",
id: "missingComic",
minWidth: 250,
accessorFn: (row: any) => row,
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
},
],
},
@@ -151,109 +196,163 @@ export const Library = (): ReactElement => {
[],
);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
const columns = useMemo(
() => [
{
query: {},
},
header: "Comic Metadata",
columns: [
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo 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: "all",
trigger: "libraryPage",
},
),
);
}, []);
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
header: "File Details",
id: "fileDetails",
minWidth: 250,
accessorKey: "_source",
cell: (info: any) => {
const source = info.getValue();
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Library</h1>
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info: any) =>
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
},
],
},
{
header: "Additional Metadata",
columns: [
{
header: "Date of Import",
accessorKey: "_source.createdAt",
cell: (info: any) =>
!isNil(info.getValue()) ? (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
{format(parseISO(info.getValue()), "h aaaa")}
</div>
{!isEmpty(searchResults) ? (
<div>
<div className="library">
) : null,
},
{
header: "Downloads",
accessorKey: "_source.acquisition",
cell: (info: any) => (
<div className="flex flex-col gap-2 ml-3 my-3">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
DC++: {info.getValue().directconnect.downloads.length}
</span>
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
Torrent: {info.getValue().torrent.length}
</span>
</div>
),
},
],
},
],
[missingIdSet],
);
const FilterDropdown = () => (
<div className="relative">
<select
value={activeFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
</div>
);
const isMissingFilter = activeFilter === "missingFiles";
return (
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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">
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>
{isMissingFilter ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{isMissingLoading ? (
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
) : (
<T2Table
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
columns={missingFilesColumns}
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
rowClickHandler={navigateToMissingComicDetail}
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
>
<FilterDropdown />
</T2Table>
)}
</div>
) : !isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits?.hits}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
/>
getRowClassName={(row) =>
missingIdSet.has(row.original._id)
? "bg-card-missing/40 hover:bg-card-missing/20"
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
}
paginationHandlers={{ nextPage, previousPage }}
>
<div className="flex items-center gap-2">
<FilterDropdown />
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
</div>
</T2Table>
</div>
) : (
<div className="columns">
<div className="column is-two-thirds">
<article className="message is-link">
<div className="message-body">
No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come
back.
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div>
<p>
No comics were found in the library, Elasticsearch reports no indices. Try
importing a few comics into the library and come back.
</p>
</div>
</article>
<pre>
{!isUndefined(searchError.data) &&
JSON.stringify(
searchError.data.meta.body.error.root_cause,
null,
4,
)}
</pre>
</div>
<FilterDropdown />
</div>
)}
</div>
</section>
);
};

View File

@@ -12,7 +12,7 @@ import { useDispatch, useSelector } from "react-redux";
import { getComicBooks } from "../../actions/fileops.actions";
import { isNil, isEmpty, isUndefined } from "lodash";
import Masonry from "react-masonry-css";
import Card from "../Carda";
import Card from "../shared/Carda";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
@@ -72,8 +72,11 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
>
<div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && (
<span className="icon cv-icon is-small">
<img src="/dist/img/cvlogo.svg" />
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
<img
src="/src/client/assets/img/cvlogo.svg"
className="w-full h-full object-contain"
/>
</span>
)}
{isNil(rawFileDetails) && (

View File

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

View File

@@ -1,126 +0,0 @@
import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../actions/comicinfo.actions";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
interface MatchResultProps {
matchData: any;
comicObjectId: string;
}
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
};
export const MatchResult = (props: MatchResultProps) => {
const dispatch = useDispatch();
const applyCVMatch = useCallback(
(match, comicObjectId) => {
dispatch(applyComicVineMatch(match, comicObjectId));
},
[dispatch],
);
return (
<>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
issueDescription = convert(match.description, {
baseElements: {
selectors: ["p"],
},
});
}
return (
<div className="search-result mb-4" key={idx}>
<div className="columns">
<div className="column is-one-fifth">
<img
className="cover-image"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div className="search-result-details column">
<div className="is-size-5">{match.name}</div>
<div className="field is-grouped is-grouped-multiline mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary">
{match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div>
<div className="is-size-7">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="vertical-line"></div>
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img
src={match.volumeInformation.results.image.icon_url}
className="cover-image"
onError={handleBrokenImage}
/>
</div>
<div className="column">
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Total Issues</span>
<span className="tag is-warning">
{match.volumeInformation.results.count_of_issues}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<button
className="button is-normal is-outlined is-primary is-light is-pulled-right"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="icon is-size-5">
<i className="fas fa-clipboard-check"></i>
</span>
<span>Apply Match</span>
</button>
</div>
</div>
</div>
);
})}
</>
);
};
export default MatchResult;

View File

@@ -1,261 +0,0 @@
import React, { useContext } from "react";
import { SearchBar } from "./GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { useSelector } from "react-redux";
import { isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns";
const Navbar: React.FunctionComponent = (props) => {
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="/" className="navbar-item">
<img
src="/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>
{downloadProgressTick && <div className="pulsating-circle"></div>}
</a>
{!isUndefined(downloadProgressTick) ? (
<div className="navbar-dropdown is-right">
<a className="navbar-item">
<DownloadProgressTick data={downloadProgressTick} />
</a> </div>
) : null}
</div>
{/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{airDCPPSocketConnectionStatus ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pt-4 pr-2 pl-2 is-right airdcpp-status">
{/* AirDC++ Session Information */}
<p>
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(airDCPPSessionInfo.user.last_login),
"dd MMMM, yyyy",
)}
</span>
</p>
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInfo.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.session_id}
</span>
</p>
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
</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">
<pre>
{JSON.stringify(socketDisconnectionReason, 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

@@ -1,25 +1,23 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import T2Table from "../shared/T2Table";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { useDispatch, useSelector } from "react-redux";
import Card from "../Carda";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { isNil } from "lodash";
export const PullList = (): ReactElement => {
const pullListComics = useSelector(
(state: RootState) => state.comicInfo.pullList,
);
// const pullListComics = useSelector(
// (state: RootState) => state.comicInfo.pullList,
// );
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2022-11-15",
pageSize: "15",
currentPage: "1",
}),
);
// dispatch(
// getWeeklyPullList({
// startDate: "2023-7-28",
// pageSize: "15",
// currentPage: "1",
// }),
// );
}, []);
const nextPageHandler = () => {};
const previousPageHandler = () => {};
@@ -101,7 +99,7 @@ export const PullList = (): ReactElement => {
[],
);
return (
<section className="container">
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="section">
<div className="header-area">
<h1 className="title">Weekly Pull List</h1>

View File

@@ -1,181 +0,0 @@
import React, { useMemo, useCallback, ReactElement } from "react";
import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { importToDB } from "../actions/fileops.actions";
import { useSelector, useDispatch } from "react-redux";
import { comicinfoAPICall } from "../actions/comicinfo.actions";
import { search } from "../services/api/SearchApi";
import { Form, Field } from "react-final-form";
import Card from "./Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import dayjs from "dayjs";
interface ISearchProps {}
export const Search = ({}: ISearchProps): ReactElement => {
const formData = {
search: "",
};
const dispatch = useDispatch();
const getCVSearchResults = useCallback(
(searchQuery) => {
dispatch(
comicinfoAPICall({
callURIAction: "search",
callMethod: "GET",
callParams: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery.search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
}),
);
},
[dispatch],
);
const addToLibrary = useCallback(
(sourceName: string, comicData) =>
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return (
<>
<section className="container">
<div className="section search">
<h1 className="title">Search</h1>
<Form
onSubmit={getCVSearchResults}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit} className="form columns search">
<div className="column is-three-quarters search">
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="input main-search-bar is-large"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<div className="column">
<button type="submit" className="button is-medium">
Search
</button>
</div>
</form>
)}
/>
{!isNil(comicVineSearchResults.results) &&
!isEmpty(comicVineSearchResults.results) ? (
<div className="columns is-multiline">
{comicVineSearchResults.results.map((result) => {
return (
<div
key={result.id}
className="comicvine-metadata column is-10 mb-3"
>
<div className="columns">
<div className="column is-one-quarter">
<Card
key={result.id}
orientation={"vertical"}
imageUrl={result.image.small_url}
title={result.name}
hasDetails={false}
></Card>
</div>
<div className="column">
<div className="is-size-3">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning">
{result.id}
</span>
</div>
</div>
</div>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p>
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p"],
},
}),
320,
)}
</p>
<button
className="button is-success is-light is-outlined mt-2"
onClick={() => addToLibrary("comicvine", result)}
>
<i className="fa-solid fa-plus mr-2"></i> Want
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<article className="message is-dark is-half">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Search the ComicVine database
</span>
Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or
torrent clients.
</p>
</div>
</article>
)}
</div>
</section>
</>
);
};
export default Search;

View File

@@ -0,0 +1,471 @@
import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
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 { useMutation, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {}
export const Search = ({}: ISearchProps): ReactElement => {
const queryClient = useQueryClient();
const formData = {
search: "",
};
const [comicVineMetadata, setComicVineMetadata] = useState({});
const [selectedResource, setSelectedResource] = useState("volume");
const { t } = useTranslation();
const handleResourceChange = (value) => {
setSelectedResource(value);
};
const {
mutate,
data: comicVineSearchResults,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (data: { search: string; resource: string }) => {
const { search, resource } = data;
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,
},
});
},
});
// add to library
const { data: additionResult, mutate: addToWantedList } = useMutation({
mutationFn: async ({
source,
comicObject,
markEntireVolumeWanted,
resourceType,
}) => {
let volumeInformation = {};
let issues = [];
switch (resourceType) {
case "issue":
const { id, api_detail_url, image, cover_date, issue_number } =
comicObject;
// Add issue metadata
issues.push({
id,
url: api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
// 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:
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 },
},
},
});
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const createDescriptionMarkup = (html) => {
return { __html: html };
};
const onSubmit = async (values) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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
onSubmit={onSubmit}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<div className="flex flex-row w-full">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
<div className="w-10 text-gray-400">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
placeholder="Type an issue/volume name"
/>
);
}}
</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
</button>
</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>
)}
/>
</div>
{isPending && (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Loading results...
</div>
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{comicVineSearchResults.data.results.map((result) => {
return result.resource_type === "issue" ? (
<div
key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
{result.cover_date && (
<p>
<span className="tag is-light">Cover date</span>
{dayjs(result.cover_date).format("MMM D, YYYY")}
</p>
)}
<p className="tag is-warning">{result.id}</p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`This will add ${result.volume.name} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
</div>
</div>
</div>
) : (
result.resource_type === "volume" && (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>}
</div>
<div className="flex flex-row gap-2">
{/* issue count */}
{result.count_of_issues && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{t("issueWithCount", {
count: result.count_of_issues,
})}
</span>
</span>
</div>
)}
{/* type: TPB, one-shot, graphic novel etc. */}
{!isNil(result.description) &&
!isUndefined(result.description) && (
<>
{!isEmpty(
detectIssueTypes(result.description),
) && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
</div>
)}
</>
)}
</div>
<span className="tag is-warning">{result.id}</span>
<p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`Adding this volume will add ${t(
"issueWithCount",
{
count: result.count_of_issues,
},
)} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: true,
resourceType: "volume",
})
}
/>
</div>
</div>
</div>
</div>
)
);
})}
</div>
) : (
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<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> 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
library. Then, download them using the configured AirDC++ or
torrent clients.
</p>
</div>
</article>
</div>
)}
</section>
</div>
);
};
export default Search;

View File

@@ -0,0 +1,25 @@
import React, { ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";
import { getServiceStatus } from "../../actions/fileops.actions";
export const ServiceStatuses = (): ReactElement => {
const serviceStatus = useSelector(
(state: RootState) => state.fileOps.libraryServiceStatus,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getServiceStatus());
}, []);
return (
<div className="is-clearfix">
<div className="mt-4">
<h3 className="title">Core Services</h3>
<h6 className="subtitle has-text-grey-light">
Statuses for core services
</h6>
</div>
<pre>{JSON.stringify(serviceStatus, null, 2)}</pre>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More