Compare commits

...

603 Commits

Author SHA1 Message Date
c6f719e78b 💅🏼 Formatting tweaks to tabs 2024-03-30 21:28:48 -04:00
b4d1b678b1 💅🏼 Formatting improvements 2024-03-29 23:26:43 -04:00
aa3192bc1a 🧲 Added visual indicators of torrent progress 2024-03-29 19:35:57 -04:00
56ddfbd16e 🧲 Surfacing torrent progress in UI via scheduled job 2024-03-27 22:23:24 -05:00
173735da45 🧲 Added downloads panel 2024-03-24 17:30:51 -04:00
f4408cd493 🧲 Fixed the auto-population of search box 2024-03-16 18:34:41 -04:00
41a9428729 🧲 Added a torrent download sub-panel 2024-03-07 05:50:49 -06:00
e1da6ddcef 🪢 Wiring up to addTorrent endpoint 2024-02-27 22:15: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
fb2c969c00 💵 Added a funding page (#60) 2023-01-28 09:28:22 -08:00
d437fddd1a Merge branch 'master' of https://github.com/rishighan/threetwo 2023-01-21 18:31:00 -08:00
8dd68e9d54 AirDC++ Socket Status (#58) 2023-01-21 02:29:32 -08:00
dependabot[bot]
a8b52d0ac6 Bump json5 from 1.0.1 to 1.0.2 (#56)
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-08 08:49:09 -08:00
719ebe7c6e ThreeTwo favicon (#54)
* 🔧 Fixed empty library state with explanation

* ️ Added a status indicator for the AirDC++ socket connection

* 🖼️ Fixed the ThreeTwo favico
2023-01-01 17:14:27 -08:00
ddef87ea29 AirDC++ Connection Status (#53)
* 🔧 Fixed empty library state with explanation

* ️ Added a status indicator for the AirDC++ socket connection
2022-12-30 22:39:38 -08:00
71bad167ab 🔧 Library zero state (#52)
* 🔧 Refactoring uncompression methods on client-side

* ✏️ Refactoring

* 👁️ Updates to the comic viewer

* 🖼️ Added screenshots from December 2022

* ✏️ Fixed typo in README

* 🏗️ Massive refactor around archive uncompression for reading/analysis

* 🔧 Tweaked state vars for reading and analysis

* 🏗️ Refactor to support DC++ and socket.io integration

This refactor covers the following workflows:

1. Adding a comic from LOCG or ComicVine adds it to the wanted list
2. Downloading that comic from DC++ correctly adds download metadata to the corresponding comic object in mongo
3. Successful download triggers automatic import to library and cover extraction, metadata application
2022-12-29 15:55:33 -08:00
df1fbc7dd3 🔧 Fixed empty library state with explanation 2022-12-29 23:33:23 +00:00
789e5b9518 Merge branch 'master' of https://github.com/rishighan/threetwo 2022-12-21 21:49:42 -08:00
ce6653b5d7 🏗️ Applying the refactor patc 2022-12-21 21:49:12 -08:00
d065225d8e 🏗️ Refactoring archive uncompression for "Read Comic" and "Analysis" user flows (#46)
* 🔧 Refactoring uncompression methods on client-side

* ✏️ Refactoring

* 👁️ Updates to the comic viewer

* 🖼️ Added screenshots from December 2022

* ✏️ Fixed typo in README

* 🏗️ Massive refactor around archive uncompression for reading/analysis

* 🔧 Tweaked state vars for reading and analysis

* 🏗️ Refactor to support DC++ and socket.io integration

This refactor covers the following workflows:

1. Adding a comic from LOCG or ComicVine adds it to the wanted list
2. Downloading that comic from DC++ correctly adds download metadata to the corresponding comic object in mongo
3. Successful download triggers automatic import to library and cover extraction, metadata application
2022-12-21 21:17:38 -08:00
dependabot[bot]
f854ff9cc6 Bump decode-uri-component from 0.2.0 to 0.2.2 (#45)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-06 20:35:38 -08:00
815444a973 🔧 Refactoring the uncompression methods for "Read Comic" and "Uncompress Archive" operations (#43)
* 🔧 Refactoring uncompression methods on client-side

* ✏️ Refactoring

* 👁️ Updates to the comic viewer

* 🖼️ Added screenshots from December 2022

* ✏️ Fixed typo in README
2022-12-06 14:08:53 -08:00
1cbf53be98 🖼️ Adding screenshots as of December 2022 (#44) 2022-12-06 14:07:44 -08:00
0e73f9dad1 Merge branch 'master' of https://github.com/rishighan/threetwo 2022-11-24 22:00:25 -08:00
a15168a6be 📝 Added JSDoc to extractComicArchive method 2022-11-24 21:51:49 -08:00
dependabot[bot]
67079f0cb4 Bump engine.io from 6.2.0 to 6.2.1 (#42)
Bumps [engine.io](https://github.com/socketio/engine.io) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/6.2.0...6.2.1)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-22 10:03:42 -08:00
ef5af01e33 ✏️ Code cleanup and adding jsDoc 2022-11-19 11:23:44 -08:00
5b6c9c8ffc ✏️ Added jsDoc for some components 2022-11-17 15:55:52 -08:00
4556778a47 🔧 Tweaking layout of the CV manual search form 2022-11-17 14:06:26 -08:00
af5f443cbe 🔧 Fixing CV manual search form 2022-11-17 13:46:20 -08:00
7babf9f73d 🔧 🏗️ Massive tables refactor
Abstracted a table component that can be configured to display issues, volumes or pull list items
2022-11-15 17:22:50 -08:00
1e39daeda2 🔧 Fixed wanted comics table data source 2022-11-08 10:05:30 -08:00
e3cea24615 🔧 Using the refactored T2Table for Wanted Comics 2022-11-07 21:15:02 -08:00
f60c9e4e67 🔧 Refactored the T2Table component with the new pagination controls 2022-11-07 20:55:58 -08:00
27e6f26331 ✏️ Changed the hard-coded date of the pull list 2022-11-07 08:06:09 -08:00
1f5502ce23 👁️ Added grid and tabular view buttons 2022-11-05 09:28:54 -07:00
3cb9588bbf 🎨 Styling tweaks to pagination controls 2022-11-03 21:40:23 -07:00
b1fb256189 🔧 Streamlined search and pagination controls on Library page 2022-11-03 12:47:31 -07:00
74ea2742f0 🔧 Fixes for controlled pagination on react-table 2022-11-01 22:21:58 -07:00
151c6ec314 🔎 Fixed search bar on Library page 2022-10-29 15:47:27 -07:00
c6a3be968a 🎨 Tweaking styling of the library table 2022-10-28 21:46:16 -07:00
ff5ce10e17 🔧 Library page table pagination 2022-10-27 23:06:40 -07:00
63e96bf96e 🚏 Added a path to comic detail row on Library page 2022-10-17 11:30:30 -07:00
b699a90a00 🕵🏼 Added manual search form for CV matching 2022-09-28 09:51:48 -07:00
eda11d3537 🐛 Fixed bug on the AirDC++ settings page 2022-09-19 15:41:32 -07:00
262f8d49d7 🔼 Bumped up react-final-form 2022-09-12 12:50:30 -07:00
6b1bb02d57 📝 Added a port number field to AirDC++ settings form 2022-09-08 10:35:30 -07:00
8d114ff04d 🎨 Minor prettification of bundleID for downloads 2022-08-20 09:49:58 -07:00
9bbf2efc3c 🪢 Unwrangling state vars for global search vs local 2022-08-18 00:10:31 -07:00
5f59456c8b 🔧 Fixed publisher name in Library table 2022-08-17 22:25:48 -07:00
ea366d1888 🔧 Fixes for Wanted comics table 2022-08-17 20:48:10 -07:00
014ea27752 🪢 Wiring up download complete action -> import 2022-08-07 22:35:56 -07:00
580d19e8a4 🔽 Downloads section building out 2022-07-30 21:38:56 -07:00
f146dfdd0b 📝 Tabulated downloads per comic 2022-07-28 08:59:30 -07:00
602adf8775 🔧 Refactoring the way bundles are saved 2022-07-27 22:49:00 -07:00
fb40fe86b5 🔽 Wiring up downloads API calls and actions 2022-07-26 22:26:24 -07:00
a3aa46bca3 Merge branch 'master' of https://github.com/rishighan/threetwo 2022-07-24 22:20:31 -07:00
365fa2e115 🔎 Integration with DC++ downloads endpoint 2022-07-24 22:20:25 -07:00
d2de78968d Merge pull request #38 from rishighan/dependabot/npm_and_yarn/moment-2.29.4
Bump moment from 2.29.3 to 2.29.4
2022-07-24 22:00:50 -07:00
dependabot[bot]
684730f186 Bump moment from 2.29.3 to 2.29.4
Bumps [moment](https://github.com/moment/moment) from 2.29.3 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.3...2.29.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-25 04:59:52 +00:00
fad18bc270 Merge pull request #37 from rishighan/dependabot/npm_and_yarn/terser-4.8.1
Bump terser from 4.8.0 to 4.8.1
2022-07-24 21:59:02 -07:00
dependabot[bot]
67d66cefcf Bump terser from 4.8.0 to 4.8.1
Bumps [terser](https://github.com/terser/terser) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 06:03:04 +00:00
f865feeeb2 ⤵️ Downloads page scaffold 2022-07-10 21:11:56 -07:00
23a0d2c231 🧩 MetadataPanel story added 2022-07-06 16:07:48 -07:00
2ce7ad2297 🌁 Added a story for Card 2022-07-06 10:40:33 -07:00
a10c8b7745 📘Storybook configuration added 2022-07-05 20:56:23 -07:00
181de14f26 ✏️ Updated README 2022-07-05 11:16:58 -07:00
2ab27926f6 🧩 react-table 8.1.1 breaking changes fixes 2022-07-03 21:59:59 -07:00
a4beae5d95 🔼 Bumped react-table to 8.1.1 2022-07-02 13:44:33 -07:00
ff63944810 🔧 Tweaking the masonry breakpoints 2022-06-30 08:37:35 -07:00
ab6585ca0c 🏀 Added debounce to global searchbar 2022-06-27 15:14:53 -07:00
44cf66b6c5 📥 Download nav statbar indicator 2022-06-26 11:16:11 -07:00
2dea9e1096 📥 Refactoring download tick panel 2022-06-23 11:11:45 -07:00
d2dbb133b3 🔧 Fixing state and context issues WIP for ADCPP socket 2022-06-21 14:39:37 -07:00
702f4e62a2 🔧 Fixing the AirDCPP socket issue on AcquisitionPanel on ComicDetails page 2022-06-21 12:43:35 -07:00
34e475d3cc 🔧 Fix for save/delete functionality on AirDCPP settings form 2022-06-20 00:34:46 -07:00
9cec1c40a8 🏗 WIP fixes for AirDCPP socket connection Part Deux 2022-06-17 22:45:02 -07:00
2244d2f512 🔧 More fixes for the react-refresh plugin bugs 2022-06-14 20:25:26 -07:00
a46eebb043 🔧 Fix for react-refresh plugin in production 2022-06-14 13:04:29 -07:00
7a3e0def34 🏗 Actions, reducers for Downloads 2022-06-13 14:56:15 -07:00
15c0840c63 📜 Added configuration for jsdoc + better-docs 2022-06-12 22:54:36 -07:00
f308ec0f01 🔧 Various AirDCPPSocketContext-related fixes 2022-06-10 18:39:27 -07:00
a73250d99c 🏗 Refactoring AirDCPPSocket init and download handling 2022-06-09 00:55:39 -07:00
2943000db3 🔧 Adding a download status to navbar WIP 2022-06-05 10:31:06 -07:00
5735f09431 Merge branch 'master' of https://github.com/rishighan/threetwo 2022-06-03 09:41:45 -07:00
8ce44daf9a ⤵️ Downloads page WIP 2022-06-03 09:41:40 -07:00
6b5128ac30 Merge pull request #30 from rishighan/dependabot/npm_and_yarn/npm-8.11.0
Bump npm from 7.24.2 to 8.11.0
2022-06-02 08:50:01 -07:00
dependabot[bot]
6e7b489836 Bump npm from 7.24.2 to 8.11.0
Bumps [npm](https://github.com/npm/cli) from 7.24.2 to 8.11.0.
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v7.24.2...v8.11.0)

---
updated-dependencies:
- dependency-name: npm
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-02 15:49:18 +00:00
ad07d85944 🖼 Added descriptive icons next to dashboard headers 2022-06-02 01:06:21 -07:00
32156a4efc Merge branch 'master' of https://github.com/rishighan/threetwo 2022-06-01 23:17:59 -07:00
aeb6e5225d 🔎 CV search results prettification 2022-06-01 23:17:53 -07:00
94eaf8d146 Merge pull request #29 from rishighan/dependabot/npm_and_yarn/sharp-0.30.5 2022-06-01 16:54:39 -07:00
dependabot[bot]
cbb579dda4 Bump sharp from 0.28.3 to 0.30.5
Bumps [sharp](https://github.com/lovell/sharp) from 0.28.3 to 0.30.5.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Changelog](https://github.com/lovell/sharp/blob/main/docs/changelog.md)
- [Commits](https://github.com/lovell/sharp/compare/v0.28.3...v0.30.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-01 23:48:16 +00:00
f6d86199dd 🔧 Tweaking appearance of search results 2022-05-29 09:40:46 -07:00
4ec45352e9 🔎 Glorious universal library search first draft 2022-05-26 22:52:07 -07:00
a2044941a6 Pruned useless routing scaffold 2022-05-25 10:39:11 -07:00
5596da2f2f Removed OPDS routes from the UI facade 2022-05-25 09:08:46 -07:00
Rishi Ghan
794a34d7f2 🔀 Re-arranged some nav items 2022-05-24 15:34:14 -07:00
Rishi Ghan
aba16c3708 🔎 Adding universal Library search bar WIP 2022-05-24 15:29:36 -07:00
Rishi Ghan
d50a5ada02 🔧 Moved metadata panels to its own component 2022-05-24 14:03:07 -07:00
a156f07598 🔧 Metadata Panel WIP 2 2022-05-24 11:06:55 -07:00
33976f0f63 🎛 Metadata panel WIP 2022-05-23 23:01:20 -07:00
d80c672cd1 🚛 Massive refactor of dashboard 2022-05-23 22:01:51 -07:00
f0505d7428 🖼 Added a League of Comic Geeks logo 2022-05-22 01:55:51 -07:00
3173cbf873 🔧 Fix for https://github.com/rishighan/threetwo/issues/10 2022-05-19 22:57:26 -07:00
9be9750d7b 🔧 Fixed the tab-switching bug 2022-05-17 10:44:06 -07:00
6c673dff2b 🔧 Debugging 2022-05-17 10:31:34 -07:00
45be01a140 🕷 Gnarly tab switch bug regressed 2022-05-17 08:56:12 -07:00
1a6e28e55d 🔧 Cleaned up the reference card UI 2022-05-15 23:04:30 -07:00
b754b75eb6 🌁 Added reference card for dc++ downloads 2022-05-13 22:21:40 -07:00
8f79da2eab Removed useless imports from ComicDetail 2022-05-12 21:55:30 -07:00
Rishi Ghan
2b74aaa8f5 🔧 Some spacing tweaks 2022-05-12 14:21:23 -07:00
Rishi Ghan
a64a8b5410 🔧 Fixed a nagging default active tab issue on comic detail page 2022-05-12 14:12:59 -07:00
6b508d4c36 🔫 Tweaking the trigger for download complete action 2022-05-11 23:40:10 -07:00
Rishi Ghan
30ee5e4a67 🔧 Search page refactoring 2022-05-10 14:57:46 -07:00
Rishi Ghan
5e7496028f 🔧 Wiring up the modal and the comic viewer 2022-05-05 13:56:07 -07:00
a0d29b5086 🏗 Comic viewer scaffold WIP 2022-05-04 22:47:08 -07:00
Rishi Ghan
349e74b123 👓 Fixed histogram scaling on retina displays 2022-05-03 14:06:55 -07:00
c3d8a3db74 📏 Adjusted histogram height 2022-05-03 09:42:24 -07:00
bb54f3e107 🔧 Socket.io-zation of the dashboard 2022-05-01 16:45:58 -07:00
1fab50a92a 🔧 Refactored bulk unzip endpoint wired up 2022-04-28 22:37:31 -07:00
e243d7c795 🔧 Building out full uncompression integration 2022-04-28 08:17:41 -07:00
Rishi Ghan
ee5ba474ee 🔧 Rewiring for the new uncompression endpoint 2022-04-26 14:51:28 -07:00
52daf4781b 🔧 Refactoring 2022-04-25 12:50:54 -07:00
2b4f56d51c 🔧 Refactoring to hook on to the file completed event 2022-04-25 12:13:11 -07:00
544d359501 🔧 Refactoring AirDC++ downloads 2022-04-22 09:13:44 -07:00
Rishi Ghan
a40b08c990 ✍🏽 Manual search override for AirDC++ query 2022-04-19 14:36:21 -07:00
ab19f37007 🖼 UX tweaks on Acquisition Panel 2022-04-19 07:54:06 -07:00
d17f49baf4 🔎 DC++ manual search scaffold 2022-04-19 01:01:34 -07:00
253c7357a0 🔧 Fixed some issues with axios cached instance 2022-04-18 12:08:33 -07:00
423aacca2a Merge branch 'master' of https://github.com/rishighan/threetwo
# Conflicts:
#	yarn.lock
2022-04-18 10:51:56 -07:00
90889ea2f1 🔧 Updated yarn.lock 2022-04-18 10:51:35 -07:00
8927856116 Reverted the changes 2022-04-18 09:39:25 -07:00
9d4faccb35 ✏️ Editing workflow II 2022-04-18 09:14:55 -07:00
241be87ec0 ✏️ Editing workflow 2022-04-18 09:13:29 -07:00
2cdf81fc04 🔼 Updated Github docker workflow to v4 2022-04-18 09:00:38 -07:00
f4b2ae2bff Merge pull request #28 from rishighan/dependabot/npm_and_yarn/async-2.6.4
Bump async from 2.6.3 to 2.6.4
2022-04-18 08:38:06 -07:00
dependabot[bot]
f4f39d4ec9 Bump async from 2.6.3 to 2.6.4
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-18 15:37:09 +00:00
32125c074d Merge branch 'master' of https://github.com/rishighan/threetwo
# Conflicts:
#	yarn.lock
2022-04-18 08:36:15 -07:00
31fd22a291 👂🏼Added an event listener for ADCPP queue file status 2022-04-18 08:34:44 -07:00
3d1a664d49 Merge pull request #27 from rishighan/dependabot/npm_and_yarn/urijs-1.19.11
Bump urijs from 1.19.10 to 1.19.11
2022-04-13 14:38:57 -07:00
dependabot[bot]
9e9c2849b5 Bump urijs from 1.19.10 to 1.19.11
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.10 to 1.19.11.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.10...v1.19.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-13 00:15:45 +00:00
ce683feb11 🔢 Added issue number to the issues contained in a volume 2022-04-12 08:35:22 -07:00
2990fa354f 🖼 UX work on volume details page 2022-04-11 14:30:34 -07:00
f94540616d 🔧 Tweaked the issues-for-series call 2022-04-11 14:18:47 -07:00
312ee93781 🏗 Added a downloads navbar link 2022-04-10 00:49:12 -07:00
771400fdf3 🏗 Scaffold for tabulated volumes page WIP 2022-04-10 00:31:35 -07:00
6a8d729ad9 🔧 Fixes 2022-04-09 21:55:04 -07:00
6d89b94425 Merge branch 'master' of https://github.com/rishighan/threetwo 2022-04-09 21:54:58 -07:00
115571e297 🔧 Fixed issues with the Wanted table view 2022-04-09 21:54:52 -07:00
93bd0f949c Merge pull request #26 from rishighan/dependabot/npm_and_yarn/moment-2.29.2
Bump moment from 2.29.1 to 2.29.2
2022-04-09 08:31:00 -07:00
dependabot[bot]
ec5427f53b Bump moment from 2.29.1 to 2.29.2
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-09 10:51:24 +00:00
99f6991896 🔧 Added a call to fetch volumes 2022-04-09 01:46:08 -07:00
1f26fe5cfa 🔧 Fixed some glaring errors 2022-04-08 23:50:55 -07:00
c4f46cc727 🏗 WIP scaffold for Volumes tabulated view 2022-04-08 22:47:57 -07:00
a3f076add3 🖼 Building out the tabulated Wanted Comics page 2022-04-07 16:12:34 -07:00
c91f64239a 🏗 Scaffold for Wanted list table 2022-04-07 01:03:46 -07:00
0a315b1ef9 🧹 Cleaned up the deck/description for CV sourced volumes 2022-04-06 23:08:53 -07:00
81bdaefdd1 📋 UI improvements in the Library section 2022-04-06 11:00:16 -07:00
0cd142153c 🖼 Some UI fixes 2022-04-06 00:10:36 -07:00
0d0fd948b5 🔧 Some UI flourishes 2022-04-05 01:18:03 -07:00
89ca89752c 🤯 Added ES index delete option in the UI 2022-04-04 22:57:02 -07:00
ff47e3d590 🔧 Fix for AirDC++ search result not downloading 2022-04-03 22:24:31 -07:00
5bf9e88b41 🔼 Upgraded to react 18 and react-dom 18 2022-03-30 11:01:03 -07:00
c208cbb76f ✏️ Added comics and userdata folder to dockerignore 2022-03-29 13:45:59 -07:00
3d2fe78657 🔧 Updated README 2022-03-28 20:32:41 -07:00
ddd9fe958e Removed docker-compose related files 2022-03-28 20:27:21 -07:00
55f4e0ccec Removed reference to a useless logger dep 2022-03-28 10:38:53 -07:00
6dcdbd9227 🔧 Removed package-lock.json 2022-03-27 23:17:37 -07:00
7e7e042591 🔧 Adjusting paths 2022-03-27 23:13:47 -07:00
5aef96a5fb 🔧 Updated Dockerfile 2022-03-27 22:45:53 -07:00
36f743362c 🔼 Bumped up webpack, removed some useless deps 2022-03-26 22:44:00 -07:00
a0292d688a Merge pull request #23 from rishighan/dependabot/npm_and_yarn/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6
2022-03-26 22:42:30 -07:00
dependabot[bot]
aece7fc7d1 Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-27 05:35:49 +00:00
8a79f6925a 🔼 Bumped webpack-dev-server 2022-03-26 22:34:50 -07:00
7b1dc56dbb 🔽 Weekly Pull List dedicated tablulated page 2022-03-26 22:28:22 -07:00
03b982858d 🥡 Started making react-table re-usable 2022-03-26 11:27:41 -07:00
089078fda0 💅🏽 react-table UI polishes 2022-03-20 23:33:16 -07:00
2c8e339e3b 💅🏽 Refactoring UX on Library page 2022-03-20 02:31:25 -07:00
4e2a91a1ce 🤳🏼 Updated icon for ComicInfo.xml file 2022-03-18 23:17:50 -07:00
b1b4070867 💅🏽 Beautifying the import UI 2022-03-18 09:35:26 -07:00
f6ca91f530 ⏯ Added queue pause/resume controls in UI 2022-03-17 17:06:13 -07:00
f28fbfa938 🔧 Tweaking the library table list 2022-03-11 02:11:44 -08:00
d6782bcf5d Merge pull request #22 from rishighan/dependabot/npm_and_yarn/urijs-1.19.10 2022-03-08 16:46:09 -08:00
dependabot[bot]
257edbc8de Bump urijs from 1.19.9 to 1.19.10
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.9 to 1.19.10.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.9...v1.19.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-08 23:42:53 +00:00
b237d2a32d 🔧 Fixing up the UI on the library page 2022-03-08 00:54:42 -08:00
bb3e01ca24 🔎 Wiring up the Library page for elasticsearch-powered, search 2022-03-07 03:05:22 -08:00
a7a536c647 Merge pull request #21 from rishighan/dependabot/npm_and_yarn/urijs-1.19.9
Bump urijs from 1.19.8 to 1.19.9
2022-03-05 00:16:20 -08:00
cf167be8da 🔧 Fixed object orchestration for volumes in UI 2022-03-04 15:43:27 -08:00
dependabot[bot]
c5e369f42b Bump urijs from 1.19.8 to 1.19.9
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.8 to 1.19.9.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.8...v1.19.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-03 23:33:49 +00:00
103cc7fa91 🙋🏽‍♂️ Wanted comics section on dashboard 2022-03-03 14:57:22 -08:00
9ec5040bd7 🦟 Fixed 404s upon page refresh 2022-03-01 23:01:57 -08:00
769e2e3edc 🔼 Upgraded to react-router v6 2022-03-01 15:20:17 -08:00
9b8f66c8b2 🔧 Reworked metadata UX 2022-02-28 22:05:52 -08:00
6094c2489c 📉 Added a comicinfo.xml stat data point 2022-02-28 13:57:25 -08:00
ffb5d73ab8 🔧 Added visibility filters to the metadata tabs on comic details page 2022-02-28 13:42:02 -08:00
e4b04c51eb 💽 ComicInfo.xml conversion and import wiring up 2022-02-27 23:48:25 -08:00
50f59f9493 Merge pull request #20 from rishighan/dependabot/npm_and_yarn/url-parse-1.5.10
Bump url-parse from 1.5.7 to 1.5.10
2022-02-25 22:39:37 -08:00
dependabot[bot]
afb015fd7b Bump url-parse from 1.5.7 to 1.5.10
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-26 04:41:34 +00:00
b80e9acbcc Merge pull request #19 from rishighan/dependabot/npm_and_yarn/urijs-1.19.8 2022-02-25 20:40:53 -08:00
dependabot[bot]
0e41b16cb2 Bump urijs from 1.19.7 to 1.19.8
Bumps [urijs](https://github.com/medialize/URI.js) from 1.19.7 to 1.19.8.
- [Release notes](https://github.com/medialize/URI.js/releases)
- [Changelog](https://github.com/medialize/URI.js/blob/gh-pages/CHANGELOG.md)
- [Commits](https://github.com/medialize/URI.js/compare/v1.19.7...v1.19.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-26 03:29:38 +00:00
262a31e3a5 🔧 Testing a different date 2022-02-24 00:16:28 -08:00
822e43810c Merge pull request #18 from rishighan/dependabot/npm_and_yarn/url-parse-1.5.7
Bump url-parse from 1.5.4 to 1.5.7
2022-02-18 23:45:22 -08:00
dependabot[bot]
758bb4e67e Bump url-parse from 1.5.4 to 1.5.7
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.4 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.4...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 07:37:54 +00:00
573df70240 🔧 Tweaking the dashboard 2022-02-17 01:05:50 -08:00
fe1b55a35d 🔧 Stats UI cleanup 2022-02-16 09:34:43 -08:00
36f41ef168 ✏️ Fixes to README 2022-02-15 22:04:19 -08:00
3911a54e94 ✏️ Updated client folder README 2022-02-15 21:58:30 -08:00
829bc488c8 🔧 Added checks to stats modules 2022-02-15 21:25:17 -08:00
635e70ba81 🎠 Weekly Pull list carousel first draft 2022-02-15 15:31:35 -08:00
8ff12e5e02 Merge pull request #17 from rishighan/dependabot/npm_and_yarn/follow-redirects-1.14.8
Bump follow-redirects from 1.14.7 to 1.14.8
2022-02-14 18:18:27 -08:00
dependabot[bot]
82719d24b8 Bump follow-redirects from 1.14.7 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 06:25:01 +00:00
1fbf677806 🔧 Added caching to pull list call to CV 2022-02-13 22:24:16 -08:00
ce392ec13e 🔧 Refactored the volumeGroup to use volume info from mongo 2022-02-12 19:41:47 -08:00
d1a0bc7d46 🌁 Added an icon to indicate missing file 2022-02-12 12:55:49 -08:00
033c59e2d0 📊 Added a scaffold for stats 2022-02-10 21:44:06 -08:00
df2d336b48 🌈 Color histograms for images, along with stats 2022-02-10 01:22:56 -08:00
7a10fb4d35 Merge pull request #16 from rishighan/dependabot/npm_and_yarn/simple-get-3.1.1
Bump simple-get from 3.1.0 to 3.1.1
2022-02-08 22:15:35 -08:00
1bbf5db6a8 🎨 UX improvements 2022-02-08 20:50:45 -08:00
9ff048d541 🔬 Wiring up the image analysis UI with the endpoint 2022-02-08 14:48:24 -08:00
8fdd8d0226 🪢 Wired up the updated Elasticsearch call to the potential matches page 2022-02-07 09:55:21 -08:00
dependabot[bot]
2738ae54f1 Bump simple-get from 3.1.0 to 3.1.1
Bumps [simple-get](https://github.com/feross/simple-get) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/feross/simple-get/releases)
- [Commits](https://github.com/feross/simple-get/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: simple-get
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-07 07:13:33 +00:00
cf1fe451c9 🔎 Wiring up the refactored elasticsearch endpoint 2022-02-06 23:12:39 -08:00
c6265599de 🔧 Renamed IMPORT_* to LIBRARY_* 2022-02-03 00:19:32 -08:00
bcfc174829 🔎 Scaffold for ES-powered issue matching in library first draft 2022-02-02 18:17:52 -08:00
1d317abbdb 🤼‍♀️ Wiring up ES-powered issue detection 2022-02-01 23:19:43 -08:00
d819bac7f2 🔧 Bumped threetwo-ui-typings to 1.0.12 2022-02-01 11:04:09 -08:00
3cedb9238b 🔧 Abstracted filename-parser into its own npm package 2022-01-31 08:36:29 -08:00
27bd383f00 🧮 Using Masonry to grid out the issue covers in VolumeDetails 2022-01-30 02:21:47 -08:00
e9e7ff7e5f 🏗 Building out the VolumeDetail page scaffold 2022-01-29 15:55:45 -08:00
8f83a0f94f 🔧 Integrating the auto-watched files from threetwo-core-service 2022-01-26 10:11:03 -08:00
8e5ff81d5f ✏️ Updated README to reflect new service names 2022-01-24 13:22:39 -08:00
30733dc62d 🐳 Consolidating env vars in a single file 2022-01-24 00:40:34 -08:00
ca6b030746 🔧 Removed extra line 2022-01-19 21:12:51 -08:00
3876037a7f Merge branch 'master' of https://github.com/rishighan/threetwo
# Conflicts:
#	yarn.lock
2022-01-18 21:46:01 -08:00
7769ed2776 🦭 Still updating Dockerfile 2022-01-18 21:45:24 -08:00
61556c958b Merge pull request #15 from rishighan/dependabot/npm_and_yarn/engine.io-5.2.1
Bump engine.io from 5.2.0 to 5.2.1
2022-01-16 14:33:24 -08:00
dependabot[bot]
acffccfc18 Bump engine.io from 5.2.0 to 5.2.1
Bumps [engine.io](https://github.com/socketio/engine.io) from 5.2.0 to 5.2.1.
- [Release notes](https://github.com/socketio/engine.io/releases)
- [Changelog](https://github.com/socketio/engine.io/blob/5.2.1/CHANGELOG.md)
- [Commits](https://github.com/socketio/engine.io/compare/5.2.0...5.2.1)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 19:41:38 +00:00
1722b7c812 Merge pull request #14 from rishighan/dependabot/npm_and_yarn/follow-redirects-1.14.7
Bump follow-redirects from 1.14.4 to 1.14.7
2022-01-15 11:40:49 -08:00
dependabot[bot]
75bbc9851b Bump follow-redirects from 1.14.4 to 1.14.7
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.4 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.4...v1.14.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 09:36:25 +00:00
762db13b4f 🔧 Updating the Dockerfile for Threetwo 2022-01-12 22:57:15 -08:00
fe3e294584 🔧 Refactoring docker-compose in preparation for single-source install 2022-01-11 18:06:43 -08:00
ebd7891cdf ✏️ Fixed type in threetwo-core-service 2022-01-09 23:21:42 -08:00
de0c5a462a ✏️ Updated link to threetwo-metadata-service 2022-01-09 15:51:17 -08:00
a9df8db867 ✏️ Updated threetwo-core-services link in description 2022-01-08 22:14:39 -08:00
a411b696f5 🔧 Added a bunch of fields to the metadata panel 2022-01-06 22:51:24 -08:00
7bb5669c52 🔧 Fixed value update issue in the react-select dropdowns 2022-01-06 15:15:45 -08:00
0bfe580389 🔧 Added __str__ key to the series AsyncSelectPaginate populator 2022-01-06 05:17:09 -08:00
ea6b2013f5 🔧 Generecized the AsyncSelectPaginate component 2022-01-06 05:12:11 -08:00
68a0c816ec 🧑‍🤝‍🧑 Added team credits to Metadata/Metron form 2022-01-05 14:50:04 -08:00
e4826c3272 🔧 Functioning select with Metron API pre-fetching and autosuggest with pagination 2022-01-04 20:29:44 -08:00
8644b79b75 🧽 Scrubbed secret for Metron 2022-01-04 08:00:14 -08:00
2c0664506e 🦸🏻 Integrating with Metron service 2022-01-04 07:54:18 -08:00
ac563b9ce9 🖌 Beautification, added a description to CV-scraped results 2022-01-02 01:14:37 -08:00
296ebafd5f 🖌 Beautifying CV matches WIP 2022-01-02 00:29:43 -08:00
cc317196ba 🔧 Formatting the new volume matches we get in CV-scraped results 2022-01-01 17:00:26 -08:00
a964ffbf07 🔧 Wiring up the updated CV scraper call 2021-12-31 15:34:53 -08:00
41918daafa ⚙️ Added a year extractor regex in filenameparser 2021-12-20 14:57:01 -08:00
3017920fb7 🏗 Building out the metadata form 2021-12-10 11:30:54 -08:00
1bf4fd3423 🔑 Added the password back 2021-12-07 20:10:35 -08:00
3ee2527b75 Removed elastic password from compose config 2021-12-07 11:37:52 -08:00
9b65c0a97a ✏️ Edit Metadata drawer scaffold 2021-12-07 09:19:32 -08:00
03c24328ed 🔧 Fixed a long standing issue with CV-sourced comics, which should not be scraped again 2021-12-06 14:45:16 -08:00
1b57092d3b 🔧 Fixed jankiness with Downloads Panel 2021-12-06 08:42:24 -08:00
e6952b44bf 🧱 Broke out more components out of ComicDetail page 2021-12-05 10:22:18 -08:00
1974e8d857 🔧 Refactoring ComicDetail and re-organizing folder structure 2021-12-03 23:39:08 -08:00
2656646412 🔧 Breaking apart ComicDetail page 2021-12-03 09:13:18 -08:00
f371858a0c 🧱 Broke Library into components 2021-12-02 16:07:47 -08:00
ffeb434075 Merge branch 'master' of https://github.com/rishighan/threetwo 2021-12-01 14:37:40 -08:00
edf49527a0 🚽 Wiring up the flushDb call 2021-12-01 14:37:29 -08:00
e197143498 🔧 Support for refactored import process WIP 2021-12-01 14:06:41 -08:00
69ccbd3b55 💬 Updated Discord Link 2021-11-26 01:19:12 -08:00
2cc9aee22e Removed nginx container 2021-11-26 01:13:21 -08:00
0c01d11b44 🔧 Several CORS related fixes 2021-11-25 23:47:00 -08:00
95fe37e542 🚒 Trying out nginx for CORS mitigation 2021-11-24 23:15:56 -08:00
25d68edbc4 🔧 Fixed a port check 2021-11-24 15:54:09 -08:00
2d75ba25bd 🔧 Commented out elasticsearch 2021-11-24 13:01:49 -08:00
485273da21 🔧 CORS proxy server 2021-11-24 10:23:48 -08:00
ce5ad3575e 🔧 Fixed an issue with AirDC++ Socket creation 2021-11-24 08:26:04 -08:00
bde76a81b7 🔧 Fixes to docker-compose config 2021-11-24 07:38:57 -08:00
2941559a18 Removed NATS from the docker-compose config and service .env files 2021-11-24 00:11:20 -08:00
5374f38b62 🐳 Added docker-compose definition for settings service 2021-11-23 18:36:48 -08:00
84f242184e 🔧 Genericized AirDC++ settings
1. You can now enter your AirDC++ client settings in the settings menu and the UI will read from them
2. Hubs can also be selected for search
2021-11-23 17:26:48 -08:00
610038247f 🔧 Fixed positioning of the delete button 2021-11-20 22:52:00 -08:00
5251803233 🔄 Moved socket init code higher up into ComicDetail parent component 2021-11-20 02:10:52 -08:00
725c156e88 ⚙️Settings-driven AirDC++ configuration first draft 2021-11-19 13:49:39 -08:00
2104f12e8f 🔢 Bumped up ui-typings one minor version 2021-11-18 14:19:42 -08:00
ba6acaac08 🔧 Created context for AirDC++ socket and refactored all actions 2021-11-18 14:15:48 -08:00
925008bdcb 🔧 AirDC++ Settings wired up (first draft) 2021-11-17 22:09:48 -08:00
6e1b431600 🔌 AirDCPP socket refactoring 2021-11-16 21:47:58 -08:00
b34c985ff4 🏗 Wiring up Settings form 2021-11-15 21:27:04 -08:00
305c172be7 🏗 Wiring up the AirDCPP settings call 2021-11-14 21:43:47 -08:00
ced3457ea2 🖊 Form for AirDCPP settings 2021-11-12 18:54:17 -08:00
ae3711f321 🎆 Fixes to fontawesome icons 2021-11-11 20:40:01 -08:00
4f95a9712d 🍚 Sticky header WIP 2021-11-11 15:16:53 -08:00
245dd7a7c1 🍚 Sticky header for Library table 2021-11-10 21:39:08 -08:00
9e39448545 🪢Removed traefik 2021-11-10 14:07:42 -08:00
c5845a55cb 🔧 Different docompose config 2021-11-10 09:01:35 -08:00
68fcd4c3ce 🔢 Fixed app-search version number 2021-11-10 08:51:39 -08:00
245d9e9050 🔧 OSS ES docker compose config 2021-11-10 08:49:01 -08:00
7c368b9e54 🔧 Trying a different ES configuration 2021-11-10 08:34:19 -08:00
5545d8bbf5 🇵🇹 Fixed port mapping 2021-11-09 22:05:54 -08:00
e42e67d9ab 🔄 Correcting version number 2021-11-09 21:55:29 -08:00
0cf76193e4 🔢 Added a version number to elk stack 2021-11-09 21:54:25 -08:00
4cc8e92f95 🪢 Added elasticsearch to the docker-compose stack 2021-11-09 21:48:52 -08:00
6babcc4c6a 🛣 Fixed path mapping 2021-11-08 12:37:00 -08:00
b7ad2d1634 🔄 Moved volume mapping to services from the API containers 2021-11-08 12:22:34 -08:00
9d6a43a7aa Removed references to COMICVINE_API_KEY from docompose 2021-11-08 12:14:02 -08:00
b13354c9f7 🏝Added env vars for service-specific env files 2021-11-08 12:04:15 -08:00
b98ad28fbe 🪲 Trying to get to the bottom of the env var issue 2021-11-07 22:35:24 -08:00
e4750cea2f Revert "🔧 Consolidated env files for services"
This reverts commit ab09335cfb.
2021-11-07 16:37:52 -08:00
ab09335cfb 🔧 Consolidated env files for services 2021-11-07 16:32:54 -08:00
feb4bbc3b6 😬 Added braces to env var 2021-11-07 10:03:53 -08:00
10e5ca3245 🔑 Added a CV_API_KEY interpolation in the docker-compose config 2021-11-06 22:18:17 -07:00
4a108987a8 Removed API key 2021-11-06 13:41:51 -07:00
10b284c96c 🔑 Added CV API KEY to the respective env files for the containers 2021-11-05 22:35:40 -07:00
59f5f42107 🔑 Added CV Key var to importapi 2021-11-05 20:20:10 -07:00
dbd133ed8d Added it back 2021-11-04 17:39:46 -07:00
bb10b4a364 Removed the key from docker-compose 2021-11-04 17:36:35 -07:00
beb39fbf31 Removed the hard-coded hostname 2021-11-04 17:25:28 -07:00
1e74363411 🔑 Added the CV API KEY to comicvine-service container 2021-11-04 16:22:12 -07:00
3f92b36d55 🔑 Added Comic Vine key 2021-11-04 16:16:48 -07:00
5b59758654 ✏️ Fixed an egregious typo 2021-11-04 14:48:36 -07:00
8f23b5a251 🔧 Hard-coded the socket host 2021-11-04 10:20:13 -07:00
0d5f6d9228 🔧 Added an env_file key 2021-11-04 10:11:34 -07:00
f38fe2ded3 🔧 Added a list file 2021-11-04 10:09:34 -07:00
b8c932b09f 🔧 Added the ENV var to the Dockerfile 2021-11-03 23:05:54 -07:00
c7a525da0a Deleted docker-compose.env file 2021-11-03 22:58:06 -07:00
e46c5e02b2 🔧 Fixes to env var substitution 2021-11-03 22:53:39 -07:00
fd948bd2eb 🥨 A roundabout way to pass env var 2021-11-03 22:45:34 -07:00
3da522c9c0 Reverting back to a key 2021-11-03 18:29:25 -07:00
0ee372fc5d 🔧 Added entrypoint to Dockerfile 2021-11-03 18:23:45 -07:00
e1635ff258 💿 Trying another approach to passing env var in docker-compose 2021-11-03 18:05:24 -07:00
5ce04317a6 Reverting change 2021-11-03 15:43:28 -07:00
4993269540 🏝 Added env file path for threetwo container 2021-11-03 15:40:28 -07:00
f1f4375af2 🎀 Trying bind volumes in docker-compose 2021-11-03 15:38:32 -07:00
d1e81b24e4 ✏️ Changes 2021-11-03 13:34:33 -07:00
64f8cbeadb 🔧 Fixed a path 2021-11-03 13:10:20 -07:00
23a5cb383e 🔧 Testing a compose config with vars 2021-11-03 13:06:33 -07:00
a383737535 💿 Volume mapping in docker-compose configuration 2021-11-03 12:58:00 -07:00
d27f4dbe06 💿 Volumes configuration for unraid VM 2021-11-03 08:49:57 -07:00
51117c2785 🔧 Removed quotes from volume mappings 2021-11-02 01:21:33 -07:00
d2d21fc4f8 🔧 Testing changes to volume paths 2021-11-02 01:14:48 -07:00
c7286f827b 🚴🏽 Added external: true to volume definitions in docker-compose 2021-11-02 00:32:46 -07:00
25bd822b46 💾 Fixed volume mappings 2021-11-02 00:22:57 -07:00
f41cfd505e 🙅🏽‍♂️Added Nova configuration to gitignore 2021-11-02 00:09:12 -07:00
c6e755eca6 🐳 Volume mapping in docker-compose first draft 2021-11-02 00:08:31 -07:00
4209012298 🔧 Fixed the process env var 2021-11-01 16:28:04 -07:00
95a5a9bfa6 🔧 Fixed a key in docker-compose yml 2021-11-01 16:21:16 -07:00
6e3594f71b 🔧 Fixes 2021-11-01 16:11:25 -07:00
50c299a8c4 🔧 Hostname fix for the 4th time 2021-11-01 15:29:00 -07:00
616e3e5866 🐳 Changed the name of the ENV_VAR pointing to underlying host's name 2021-11-01 10:37:10 -07:00
efe5416e5f 🐳 Removed the duplicated env var 2021-11-01 09:35:33 -07:00
d90bf44210 🐳 Added a DOCKER_HOST env var 2021-11-01 09:24:31 -07:00
0a11a1862d Removed comments from docker-compose config 2021-11-01 09:12:28 -07:00
5557b2a119 🔧 Testing out a potential solution 2021-11-01 08:24:24 -07:00
95ee4d7a40 Reinstating socket connection to "rook" 2021-11-01 01:32:02 -07:00
195d9431c4 ✏️Editing socket client config 2021-11-01 01:10:29 -07:00
464e7da826 🔧 Webpack fixes 2021-11-01 00:58:58 -07:00
9003124032 🔧 Fixes 2021-11-01 00:58:33 -07:00
bc053c3862 🔧 Referring to the hostname for posterity 2021-11-01 00:42:05 -07:00
4e960d272e 🔧 Switching to container name for socket host 2021-11-01 00:26:29 -07:00
434fbd782f 🔧 Another socket host hack 2021-11-01 00:24:13 -07:00
1576ada32c 🔧 Hack for hostname 2021-11-01 00:22:04 -07:00
08811e5128 🔧 WIP fixing the socket errors in docker-compose on portainer 2021-11-01 00:08:47 -07:00
124a88cb2c 🔧 Debugging port issues with Portainer deploys 2021-10-31 23:36:50 -07:00
5964221c61 🔧 Fixes to Library 2021-10-31 17:36:38 -07:00
cdbe39c0c7 🔧 Fixes to fix the way rawFileDetails object is accessed 2021-10-31 17:33:25 -07:00
a18705dab8 🔧 Troubleshooting socket.io in docker-compose part 2 2021-10-31 09:41:41 -07:00
8fc8bf7248 🔧 Testing variations for troubleshooting socket.io issues 2021-10-30 19:04:49 -07:00
843dee30a4 🔧 AirDCPP Settings form WIP 2021-10-29 00:11:51 -07:00
2f859a7250 🐳 Removed the network mode key from docker-compose config 2021-10-28 23:19:41 -07:00
2e5214338c 🔧 Fixed typo 2021-10-28 17:06:06 -07:00
2406c68216 🔧 Updated LIBRARY_SOCKET_HOST 2021-10-28 11:54:20 -07:00
086cdb3817 🐳 Added libraryqueue to docker-compose 2021-10-28 06:29:04 -07:00
e70994c032 🔧 Fixed some static paths 2021-10-27 22:04:01 -07:00
f5879e25fe 🔧 Fixed typo 2021-10-27 21:04:46 -07:00
881b19fb18 🔧 Changing up the dist path 2021-10-27 21:02:04 -07:00
56449b3ef6 🔧 Used the right constant for socket URI 2021-10-27 19:02:57 -07:00
acca2037b4 🔧 Troubleshooting hostname issues 2021-10-27 18:44:56 -07:00
e1504c2f11 📋 Added Redis environment variable to docker-compose config 2021-10-27 14:17:21 -07:00
56d22a28a0 🔧 Changes to support BullMQ on the service layer
1. Removed socket connection from context
2. Added Redux middleware to persist socket connection
3. Removed amqplib and RabbitMQ support
4. Removed RabbitMQ from docker-compose configuration
5. Removed a proxy route to IMS from the facade
6. Refactored file actions to support the new way of socket event emitting and listening
2021-10-27 07:46:21 -07:00
8fbea27fb2 🔧 Trying out some refactoring on socket connection to Import service 2021-10-23 23:32:25 -07:00
d520f897de 🔧 Fixed a typo 2021-10-22 21:55:07 -07:00
0df9b48703 ✏️ Another typo 2021-10-21 22:07:03 -07:00
48213592f0 ✏️ Fixed a typo in rabbitMQ env var 2021-10-21 22:06:29 -07:00
824ff7ff9e 🐇 RabbitMQ connection string changes 2021-10-21 22:05:28 -07:00
1ffc725252 🐇 Added an env variable for RabbitMQ hostname inside the docker container 2021-10-21 21:03:18 -07:00
5f0e2ec06f 🔧 Added a DOCKER_HOST env var to import service 2021-10-21 16:15:09 -07:00
3983466105 Removed a useless file 2021-10-21 15:26:42 -07:00
116cbf2a62 🍬 Removed hardcoded reference to import-service
TODO: Remove the repetitive use of thumbnail generating logic
2021-10-21 12:03:58 -07:00
419962c756 Removed a funky route from the frontend API 2021-10-21 01:16:22 -07:00
31a72e4b6f 🔧 Removed one more localhost reference 2021-10-20 23:28:42 -07:00
8881576296 🚦 Added traefik configuration for comicvine-service container 2021-10-20 14:46:19 -07:00
7531d56c6a 🔧 Added a hostname generator 2021-10-20 14:38:12 -07:00
f341529e6c 🔧 Fixing a lot of static path references in actions 2021-10-20 14:13:33 -07:00
261e3ee882 🔧 Adding docker host to import service base URI 2021-10-20 09:11:33 -07:00
705ccaa51d 🔧 Passing just the hostname and not port 2021-10-20 07:55:07 -07:00
343dbf075d 🚦Added traefik configuration for Import service 2021-10-20 07:52:42 -07:00
fda7a7e36e 👀 Adding $HOSTNAME 2021-10-19 21:39:56 -07:00
5dbe7afd5e Reverting the previous change 2021-10-19 21:19:59 -07:00
7b10f28ee0 🍃 Added socket host ENV var to Dockerfile 2021-10-19 21:12:48 -07:00
be370d2a46 🥨 Added a gnarly entrypoint for resolving hostname 2021-10-19 14:58:22 -07:00
eed908b9cf 🔧 Actual change 2021-10-19 14:45:03 -07:00
a51d7a602c 🔧 Turn DOCKER_SOCKET_HOST into an interpolation 2021-10-19 14:44:47 -07:00
9024c3cce9 🔧 Added process.env support via Webpack 2021-10-19 14:32:47 -07:00
c1e9a30419 📦 Added a DefinePlugin process var in Webpack 2021-10-19 12:55:13 -07:00
b1ff7e78ba 🔧 Fixed an erroneous use of process 2021-10-19 12:46:53 -07:00
c3a91ef39f 🔧 Interpolating hostname of Docker Host 2021-10-19 12:40:13 -07:00
98e49a6e73 🔧 Trying out a docker container name as socket hostname 2021-10-19 10:21:24 -07:00
2cc8f9da87 🔧 changed the protocol for socket connection 2021-10-19 10:07:14 -07:00
984e0c4bb1 🔧 Modified the socket base hostname to 0.0.0.0 2021-10-19 09:56:30 -07:00
04968a6031 🔧 Enabled rabbitmq 2021-10-19 09:50:57 -07:00
41ab6fb9d5 🔧 Fixing a container name in docker-compose for traefik 2021-10-19 09:47:23 -07:00
d5c9d842a3 🔧 More shenanigans 2021-10-19 09:42:35 -07:00
857e8b58e0 🔧 Updates to loadbalancer port 2021-10-19 09:35:46 -07:00
eaf5616fea ✏️ Added a .localhost TLD for threetwo-ui 2021-10-19 09:28:25 -07:00
918b4dd47d 🔧 Debugging docker-compose traefik 2021-10-19 09:09:11 -07:00
d84b02ef82 🚦 Added traefik 2021-10-19 07:36:35 -07:00
84e2a10d1b 🔧 Tweak 2021-10-18 15:46:33 -07:00
2d1ccf7264 🔒 CORS configuration in docker-compose and Express 2021-10-18 12:11:30 -07:00
8b3743fe6b 🔧 Added a flag to docker-compose 2021-10-18 08:38:47 -07:00
a050a83724 🔧 Added CORS headers to nginx config 2021-10-18 08:18:50 -07:00
9df5adb69c 🔧 Added an entry for a socket passthrough 2021-10-17 22:14:51 -07:00
e9d744f4f5 🔧 Added network mode host to docker-compose services 2021-10-17 10:44:55 -07:00
f5116e1278 ✏️ Updates to deps 2021-10-15 23:15:29 -07:00
303a2cd54b ✏️ Edited name of the db service in docker-compose 2021-10-15 23:09:32 -07:00
492fd572e2 🔧 Wiring up Settings page 2021-10-15 23:02:49 -07:00
ed120fb230 📑 Added a AirDCPP Settings form WIP 2021-10-15 15:25:19 -07:00
049c4266f3 🔧 Fixed connection string interpolation for rabbitMQ in docker-compose config 2021-10-14 22:31:29 -07:00
972ba48e6f 🎮 Added a rabbitMQ connection string URL 2021-10-14 22:21:34 -07:00
86b9ec9d95 🔑 Added credentials for rabbitMQ connection 2021-10-14 18:22:36 -07:00
e878daa114 🧦 Fixed hostnames for docker-compose 2021-10-14 17:00:52 -07:00
cfd29cb4cd 🐇 Added rabbitMQ creds in docker-compose 2021-10-14 16:58:52 -07:00
c8eb2882bd 🍬 Hard-coded env file path 2021-10-14 09:30:12 -07:00
6c1a068d5e 🔧 Changed the rabbitMQ host to a env var 2021-10-14 09:18:03 -07:00
86b4223c3a 💼 Added a port mapping for rabbitMQ 2021-10-14 08:03:01 -07:00
490587735b 🔧 Switched to using service names 2021-10-13 21:10:06 -07:00
83b9a01d86 🪑 Added deps to threetwo-ui 2021-10-13 21:07:22 -07:00
c96ab4ee4a 🧹 Removed ${PWD} 2021-10-13 17:53:41 -07:00
4a5551a8f2 🔧 Fixed typo in docker-compose.yml 2021-10-13 17:46:45 -07:00
b2bcfd5422 🐳 Volume mapping debugging 2021-10-13 17:45:47 -07:00
809b753b41 🚀 Added a network to rabbitmq container 2021-10-13 08:26:40 -07:00
9121e94e91 ️ Added docker build status badge 2021-10-12 21:06:41 -07:00
d0e3332c2c 🤬 Adding docker-compose.env back 2021-10-12 12:20:43 -07:00
d622111a73 🏝 Set PWD for volume mappings in the correct .env files for docker-compose 2021-10-12 12:14:56 -07:00
5c39de3280 🏝 Added env file for docker-compose 2021-10-12 10:36:41 -07:00
7349eb05a3 🔧 Fixed paths in docker-compose 2021-10-12 10:13:07 -07:00
bce37906e9 🧹 Cleaning up dashboard typography 2021-10-12 00:12:43 -07:00
9ff79320da 👀 Auto-adder WIP 2021-10-11 14:53:49 -07:00
53b7e94135 🔧 Fixes to search listing 2021-10-11 08:52:39 -07:00
63fecd4592 🔧 Fixed an undefined check 2021-10-08 07:57:44 -07:00
478e105e48 🕵🏼‍♀️ TPB, Mini-series, GN detection WIP 2021-10-07 12:43:00 -07:00
2a81bfcc2e 🦄 Added Discord channel details in README 2021-10-06 22:52:31 -07:00
3051680a85 💄 Beautifying Archive ops tab 2021-10-06 22:35:39 -07:00
fce8551731 💄 Card font-size tweak 2021-10-06 09:38:40 -07:00
339af88a5b 🔧 Beautifying the DnD grid 2021-10-05 16:40:10 -07:00
eba51803f6 🔧 DnD for comic book page reorganization first draft 2021-10-04 21:22:29 -07:00
eb85ede5d8 🧹 Cleaning up 2021-10-04 11:22:58 -07:00
9025085447 🧮 Functional scaffold for DnD grid 2021-10-02 12:58:21 -07:00
0a4c8fab81 🔧 Refactored the DnD grid scaffold a bit 2021-10-02 09:34:54 -07:00
75d5dc4281 🧹 Cleaning up the DnD scaffold 2021-10-01 23:41:00 -07:00
ebd5a8b95d 🐉 Drag and Drop scaffold 2021-09-30 11:53:04 -07:00
c134d2fc49 📦 Beautified carousel displaying extracted comic book images 2021-09-29 01:45:38 -07:00
b87a13d006 🪰 Making the "fly" out sliding panel reusable 2021-09-28 21:13:49 -07:00
8db52e0407 Added an object diff check to search_results_updated reducer 2021-09-25 08:52:29 -07:00
ca082b8220 👺 Fixed usage of context with sockets 2021-09-24 15:09:00 -07:00
7615e1fe52 Update README.md 2021-09-24 12:06:31 -07:00
9090ef2663 Added OPDS test instructions to README 2021-09-24 12:03:47 -07:00
51e9397055 🐞 Fixed a bug with search_results_updated listener for AirDC++ 2021-09-23 22:40:45 -07:00
2ba6e23efa 📖 Updated README 2021-09-23 22:12:50 -07:00
bbde67bb11 🍼 Cleaned up the OPDS feed 2021-09-23 18:13:11 -07:00
99e25b6cbb Merge pull request #4 from rishighan/dependabot/npm_and_yarn/tmpl-1.0.5
Bump tmpl from 1.0.4 to 1.0.5
2021-09-23 10:09:58 -07:00
6838e3c767 Update docker-image.yml 2021-09-23 09:59:29 -07:00
5ffa29a718 Update docker-image.yml 2021-09-23 08:39:30 -07:00
49d0533a5d 🎛 Switched the layout on dashboard to masonry 2021-09-22 14:54:14 -07:00
dependabot[bot]
88f20becf3 Bump tmpl from 1.0.4 to 1.0.5
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-22 07:33:12 +00:00
384f1ce81e 📚 Added volume card stacks to the dashboard 2021-09-21 22:04:05 -07:00
048c7ba6d5 📚 Volume groups component 2021-09-21 17:15:58 -07:00
a7afc358d8 🍼 OPDS feed debugging for Panels on iOS 2021-09-21 10:06:17 -07:00
3aa5ea3427 Update docker-image.yml 2021-09-20 17:38:33 -07:00
873fa28391 Update docker-image.yml 2021-09-20 14:47:54 -07:00
7d56be71f9 📚 Added a library with a masonry grid 2021-09-20 08:24:03 -07:00
8e518c93a8 🙅🏽‍♂️ Ignored yarn's error log 2021-09-18 09:27:16 -07:00
be43c163dc 🔌 Socket + RabbitMQ setup for download-client touched folders/files 2021-09-18 09:25:59 -07:00
476a55614e 🚨 Implemented a notification system for background import 2021-09-16 09:24:14 -07:00
b40f63289a Update docker-image.yml 2021-09-15 09:36:19 -07:00
ab8a3740b5 Update docker-image.yml 2021-09-15 09:30:25 -07:00
99e1ce8b4f Rename docker-image1.yml to docker-image.yml 2021-09-15 09:21:04 -07:00
25150fac3a Delete docker-image.yml 2021-09-15 09:20:48 -07:00
3add60276b 🐳 Adding a Docker hub push step 2021-09-15 09:20:34 -07:00
c3587df058 ✏️ Added a word 2021-09-14 23:30:37 -07:00
757b03e3a6 Create docker-image.yml 2021-09-14 23:25:02 -07:00
0e039fb0a8 🐰 RabbitMQ for enqueuing comic import jobs 2021-09-14 23:13:04 -07:00
e95eeb5f27 🔧 Refactoring 2021-09-13 01:26:09 -07:00
0e085f6f06 🔧 Tweaks to TPB utils 2021-09-10 00:15:12 -07:00
ed0d7cd254 📔 Trade paperback detection 1st draft 2021-09-09 13:58:43 -07:00
67c3ab807c 🗾 Trying out some informational icons 2021-09-09 01:24:44 -07:00
f04641986d 🔧 Various fixes 2021-09-08 22:52:00 -07:00
f3d94f2a75 🃏 Added a vertical settings menu scaffold 2021-09-06 23:01:44 -07:00
3c58795286 🔧 Fixed Dockerfile path 2021-09-06 18:15:31 -07:00
efc8742699 ⚙️ Added Settings page scaffold 2021-09-06 17:50:25 -07:00
060a9143e8 Proptypes validation for components in ComicDetail 2021-09-06 15:01:21 -07:00
a173b4f971 🔄 Fixed a redux store issue with download counts 2021-09-06 01:53:27 -07:00
c8061e0d5c 🧽 Cleanup of various UX aspects 2021-09-04 22:11:59 -07:00
664f359ef7 🔧 Fixed loading state for DC++ search and type errors in bluelovers fast-glob module 2021-09-04 17:30:56 -07:00
bacde33567 🌄 Fixes to card component, and adding a key to DC++ search result rows 2021-09-04 15:30:58 -07:00
cdd27a1272 🔧 Added a placeholder for the actions menu 2021-09-04 12:31:06 -07:00
c098912d6e 🔧 Fixed the styling of the actions menu dropdown 2021-09-03 14:23:56 -07:00
e202ae45a4 💧 Added react-select to implement dropdown actions menu 2021-09-03 13:33:13 -07:00
1f8e0d6ff6 🔧 Fixes to AirDCPP searching WIP
1. Fixed the iterable for search results
2. Updated README
3. Listeners still a ?
2021-09-02 14:45:44 -07:00
3fc22c74ef 🔧 Adding corrected AirDCPP integration
1. Search event listeners
2. Reducer refactoring
2021-09-02 08:18:58 -07:00
c02b512e1c 🔧 Added port information to README 2021-09-02 00:21:17 -07:00
ae7a78de4d 🔧 Replaced the sliding panel component 2021-09-02 00:16:35 -07:00
4a4e04ff2b ✏️ Added instructions to create folders 2021-09-01 15:01:41 -07:00
96e8e1fb17 🔧 More configuration changes to docker-compose 2021-09-01 14:57:47 -07:00
7b29897c9f 📝 Added newlines in README 2021-09-01 12:27:01 -07:00
66aac9e35c 🐳 docker-compose volume mappings 2021-09-01 12:25:39 -07:00
762b5cb037 🐳 Added mongo container and fixed an ENV var 2021-09-01 09:06:26 -07:00
cc8d3febdb Merge pull request #2 from rishighan/dependabot/npm_and_yarn/tar-6.1.11
Bump tar from 6.1.5 to 6.1.11
2021-08-31 16:53:39 -07:00
dependabot[bot]
b18418f9ae Bump tar from 6.1.5 to 6.1.11
Bumps [tar](https://github.com/npm/node-tar) from 6.1.5 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.5...v6.1.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-31 23:50:13 +00:00
ec914167e7 💽 Added mongo image 2021-08-31 13:22:07 -07:00
8f3fda5551 📝Edits to header in README 2021-08-31 13:03:30 -07:00
86d029ffba 📝 Edits to README 2021-08-31 12:51:53 -07:00
b3799edcc6 🐳 Added import service config 2021-08-31 12:48:37 -07:00
632b2dc267 🐳 Docker configuration change 2021-08-31 09:32:59 -07:00
5141d387b7 🐳 Added some alpine deps to the Dockerfile 2021-08-29 09:22:50 -07:00
d1fb000cf0 📝 Updated README 2021-08-28 15:25:38 -07:00
ee30a11123 🐳 Added dockerfile and README 2021-08-27 09:53:50 -07:00
482e890d95 🔧 Refactoring 2021-08-27 09:30:35 -07:00
4ebe543f6a 🔧 Refactoring the airdcpp reducer 2021-08-26 21:47:05 -07:00
2960c2dca4 ⬇️ Added bundles to downloads tab for corresponding comic book object 2021-08-25 23:07:12 -07:00
6994da072d 🔧 Fixed bugs with tab changes and state 2021-08-24 21:59:59 -07:00
140f690c77 ⬇️ Download panel first draft 2021-08-24 12:45:22 -07:00
8a1e6fb98a 🔧 AirDCPP metadata and download first draft 2021-08-23 14:34:55 -07:00
e5870fa1a8 🪛 WIP on the DCPP search results 2021-08-23 07:36:24 -07:00
0a269b33fb 🔧 Beautifying AirDC++ search results 2021-08-22 10:03:32 -07:00
9440cafe86 🔎 Displaying AirDCPP results on the UI 2021-08-20 23:53:36 -07:00
38fc53c8c3 🔧 AirDCPP UI integration 2021-08-20 15:19:29 -07:00
f6650fc18d 💄 Beautified metadata tabs 2021-08-20 09:17:27 -07:00
7aa3db125b 🌁 Generalizing the card component Part 1 2021-08-19 15:38:18 -07:00
2184b20887 🔧 Ability to import CV search result into mongo 2021-08-18 22:16:15 -07:00
d7f0bdcdfe 💽 Wiring up simple mongo import from CV result 2021-08-18 13:06:13 -07:00
d9003bf2c7 🔧 WIP AirDCPP sockets 2021-08-17 20:58:14 -07:00
9b880a2749 🔧 Updated ThreeTwo types 2021-08-17 09:55:39 -07:00
afdff65b6b 🔧 Fixed table pagination controls and counts 2021-08-16 22:11:44 -07:00
d92b2246cb 🏓 Library table styling WIP 2021-08-16 13:44:03 -07:00
7c8ee36505 🔌 Working UI integration airdcpp-socket 2021-08-14 23:40:03 -07:00
a20da523b2 🔌 AirDC++ socket scaffold 2021-08-13 08:47:11 -07:00
9aee8176b0 🪧 Logo and positioning 2021-08-12 16:44:52 -07:00
d25720caba 🪢 Wired up CV basic search 2021-08-11 23:05:37 -07:00
0baf3e436f WIP 2021-08-11 16:23:37 -07:00
a2768d18e9 🔎 Search UI build 2021-08-11 16:23:15 -07:00
2226f5cc03 🪛 Building out the search page 2021-08-10 15:11:52 -07:00
4080572c44 🔎 Search page scaffold 2021-08-10 08:22:18 -07:00
3c8a330336 🔧 Fixed a bad state and got metadata tab groups working 2021-08-09 16:02:34 -07:00
2a2d996d54 🪛 Adding details to the ComicDetail tab groups 2021-08-09 14:05:18 -07:00
49759929af 📆 Added timestamp for last CV scrape application 2021-08-07 01:14:10 -07:00
b2fb21146d 🤼‍♀️ Ability to apply a select ComicVine match first draft 2021-08-06 13:43:42 -07:00
8df48ea6ae 🏗 Scaffold for CV metadata import 2021-08-05 09:17:56 -07:00
05fb0fad2b 📝 Refactoring 2021-08-04 15:46:49 -07:00
0de430f826 Merge pull request #1 from rishighan/dependabot/npm_and_yarn/tar-6.1.5 2021-08-04 09:16:50 -07:00
7eed36134e 🔧 WIP Library 2021-08-03 22:11:16 -07:00
dependabot[bot]
f22e7e52be Bump tar from 6.1.0 to 6.1.5
Bumps [tar](https://github.com/npm/node-tar) from 6.1.0 to 6.1.5.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.0...v6.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-04 04:20:06 +00:00
24eaeff9bc 🏓 Library page scaffold 2021-08-03 13:16:45 -07:00
7c3035fcd5 ☝🏼👇🏼 Sorting the ComicVine search matches 2021-08-03 08:48:10 -07:00
802dee8857 🧪 AirDCPP Socket scaffold 2021-07-31 13:24:54 -07:00
ae5f0b2c0f 🔍 CV match scorer WIP 2021-07-31 12:01:29 -07:00
5a7f30f007 🪛 Refactoring to use the updated rendition path 2021-07-27 22:02:12 -07:00
57f7621c0e 🤼‍♀️ Comic Vine Match algorithm, 2nd draft 2021-07-22 16:07:52 -07:00
166 changed files with 18166 additions and 27650 deletions

View File

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

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
comics
userdata

View File

@@ -1,30 +1,28 @@
module.exports = {
extends: [
"plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
"plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors.
],
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
},
jsx: true // Allows for the parsing of JSX
}
},
plugins: ["@typescript-eslint"],
plugins: ["@typescript-eslint", "css-modules"],
settings: {
"import/resolver": {
node: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
extensions: [".js", ".jsx", ".ts", ".tsx"]
}
},
react: {
version: "detect", // Tells eslint-plugin-react to automatically detect the version of React to use
},
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,
},
};
"@typescript-eslint/no-var-requires": 0
}
};

20
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Docker Image CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@v5
with:
name: frishi/threetwo
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

11
.gitignore vendored
View File

@@ -4,10 +4,19 @@ comics/
docs/
userdata/
dist/
storybook-static/*
src/client/assets/scss/App.css
server/
/server/
node_modules/
src/**/*.jsx
tests/__coverage__/
tests/**/*.jsx
src/client/assets/scss/App.css.map
yarn-error.log
.nova
environment.list
.env
src/client/assets/img/missing-file.pxd
*.pxd
.parcel-cache
src/stories

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>

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;

View File

@@ -1,45 +0,0 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:18.15.0-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
WORKDIR /threetwo
COPY package.json ./
COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
COPY . .
EXPOSE 5173
ENTRYPOINT [ "npm", "start" ]

View File

@@ -0,0 +1,72 @@
# ThreeTwo!
ThreeTwo! _aims to be_ a comic book curation app.
[![Docker Image CI](https://github.com/rishighan/threetwo/actions/workflows/docker-image.yml/badge.svg)](https://github.com/rishighan/threetwo/actions/workflows/docker-image.yml)
### Screenshots
#### Dashboard
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
#### Issue View
![](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
Please help me test the early builds of `ThreeTwo!` on its official [Discord](https://discord.gg/n4HZ4j33uT)
Discuss ideas and implementations with me, and get status, progress updates!
## Dependencies
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-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
See [threetwo-docker-compose](https://github.com/rishighan/threetwo-docker-compose) for instructions on building the entire stack.
## Local Development
For debugging and troubleshooting, you can run this app locally using these steps:
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: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
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
2. What folder do my comics go in?
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
funding.yml Normal file
View File

@@ -0,0 +1 @@
github: [rishighan]

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>

View File

@@ -1,22 +1,25 @@
{
"tags": { "allowUnknownTags": true },
"source": {
"include": ["./src/"],
"includePattern": "\\.(jsx|js|ts|tsx)$"
},
"plugins": [
"better-docs/component",
"better-docs/category",
"plugins/markdown",
"node_modules/better-docs/typescript"
],
"templates": { "better-docs": { "name": "My React components" } },
"opts": {
"destination": "docs/",
"readme": "README.md",
"recurse": true,
"encoding": "utf8",
"verbose": true,
"template": "node_modules/better-docs"
}
}
"tags": {
"allowUnknownTags": false
},
"source": {
"include": [
"./src/client"
],
"includePattern": "\\.(jsx|js|ts|tsx)$"
},
"plugins": [
"plugins/markdown"
],
"opts": {
"template": "node_modules/tui-jsdoc-template",
"encoding": "utf8",
"destination": "docs/",
"recurse": true,
"verbose": true
},
"templates": {
"cleverLinks": false,
"monospaceLinks": false
}
}

View File

@@ -1,150 +1,129 @@
{
"name": "threetwo",
"version": "0.0.2",
"description": "ThreeTwo! A comic book curator.",
"version": "0.1.0",
"description": "ThreeTwo! A good comic book curator.",
"main": "server/index.js",
"typings": "server/index.js",
"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",
"docs": "jsdoc -c jsdoc.json"
"build": "vite build",
"dev": "rimraf dist && npm run build && vite",
"start": "npm run build && vite",
"docs": "jsdoc -c jsdoc.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.17",
"@bluelovers/fast-glob": "^3.0.4",
"@types/event-stream": "^3.3.34",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@fortawesome/fontawesome-free": "^6.3.0",
"@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3",
"@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",
"@types/through2": "^2.0.36",
"airdcpp-apisocket": "^2.4.1",
"antd": "^4.16.5",
"babel-polyfill": "^6.26.0",
"better-docs": "^2.3.2",
"calibre-opds": "^1.0.7",
"comlink-loader": "^2.0.0",
"ellipsize": "^0.1.0",
"event-stream": "^4.0.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.2.1",
"airdcpp-apisocket": "^2.5.0-beta.2",
"axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0",
"babel-plugin-styled-components": "^2.1.4",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.5.1",
"express": "^4.17.1",
"fastest-validator": "^1.11.0",
"filename-parser": "^1.0.2",
"final-form": "^4.20.2",
"fs-extra": "^9.1.0",
"http-response-stream": "^1.0.7",
"imghash": "^0.0.8",
"jsdoc": "^3.6.7",
"opds-extra": "^3.0.9",
"final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3",
"history": "^5.3.0",
"html-to-text": "^8.1.0",
"immer": "^10.0.3",
"jsdoc": "^3.6.10",
"keen-slider": "^6.8.6",
"lodash": "^4.17.21",
"pretty-bytes": "^5.6.0",
"react": "^17.0.1",
"react-collapsible": "^2.8.3",
"react-dom": "^17.0.1",
"react-final-form": "^6.5.3",
"react-spinners": "^0.11.0",
"react-window-dynamic-list": "^2.3.5",
"sharp": "^0.28.1",
"socket.io-client": "^4.1.2",
"threetwo-ui-typings": "^1.0.1",
"voca": "^1.4.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.10.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0",
"react-modal": "^3.15.1",
"react-popper": "^2.3.0",
"react-router": "^6.9.0",
"react-router-dom": "^6.9.0",
"react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2",
"react-sliding-pane": "^7.1.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1",
"socket.io-client": "^4.3.2",
"styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.5",
"vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34",
"ws": "^7.5.3",
"ws-calibre": "bluelovers/ws-calibre",
"xregexp": "^5.0.2"
"zustand": "^4.4.6"
},
"devDependencies": {
"@babel/cli": "^7.13.10",
"@babel/core": "^7.13.10",
"@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",
"@fortawesome/fontawesome-free": "^5.15.3",
"@root/walk": "^1.1.0",
"@iconify-json/solar": "^1.1.8",
"@iconify/tailwind": "^0.1.4",
"@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-interactions": "^7.4.1",
"@storybook/addon-links": "^7.4.1",
"@storybook/addon-onboarding": "^1.0.8",
"@storybook/blocks": "^7.4.1",
"@storybook/react": "^7.4.1",
"@storybook/react-vite": "^7.4.1",
"@storybook/testing-library": "^0.2.0",
"@tanstack/eslint-plugin-query": "^5.0.5",
"@tanstack/react-query-devtools": "^5.1.0",
"@tsconfig/node14": "^1.0.0",
"@types/ellipsize": "^0.1.1",
"@types/express": "^4.17.8",
"@types/jest": "^26.0.20",
"@types/lodash": "^4.14.168",
"@types/mongoose": "^5.7.37",
"@types/node": "^14.14.34",
"@types/pino": "^6.3.7",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.16",
"@types/unzipper": "^0.10.3",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"awesome-typescript-loader": "^5.2.1",
"axios": "^0.21.1",
"axios-rate-limit": "^1.3.0",
"babel-eslint": "^10.0.0",
"babel-loader": "^8.2.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-redux": "^7.1.25",
"autoprefixer": "^10.4.16",
"body-parser": "^1.19.0",
"buffer": "^6.0.3",
"bulma": "^0.9.3",
"clean-webpack-plugin": "^1.0.0",
"comlink": "^4.3.0",
"compromise": "^13.10.5",
"compromise-dates": "^2.0.1",
"compromise-numbers": "^1.2.0",
"compromise-sentences": "^0.2.0",
"concurrently": "^4.0.0",
"connected-react-router": "^6.9.1",
"css-loader": "^5.1.2",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"docdash": "^2.0.2",
"eslint": "^8.49.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsdoc": "^46.6.0",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"etl": "^0.6.12",
"eslint-plugin-storybook": "^0.6.13",
"express": "^4.17.1",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.3.1",
"image-webpack-loader": "^7.0.1",
"install": "^0.13.0",
"jest": "^26.6.3",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^1.4.1",
"mongoose": "^5.10.11",
"node-sass": "^5.0.0",
"node-unrar-js": "^1.0.1",
"nodemon": "^1.17.3",
"npm": "^7.9.0",
"pino": "^6.11.2",
"pino-pretty": "^4.7.1",
"jest": "^29.6.3",
"nodemon": "^3.0.1",
"postcss": "^8.4.32",
"postcss-import": "^15.1.0",
"prettier": "^2.2.1",
"qs": "^6.10.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-hot-loader": "^4.13.0",
"react-redux": "^7.2.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"redux-thunk": "^2.3.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",
"unzipper": "^0.10.11",
"url-loader": "^1.0.1",
"webpack": "^5.33.2",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^3.11.2",
"webpack-merge": "^5.7.3"
"react-refresh": "^0.14.0",
"rimraf": "^4.1.3",
"sass": "^1.69.5",
"storybook": "^7.3.2",
"tailwindcss": "^3.4.1",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6"
},
"resolutions": {
"jackspeak": "2.1.1"
}
}

7
postcss.config.js Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -1,159 +1,17 @@
# Client side boilerplate with ReactJS library and Typescript
## ThreeTwo UI
## Introduction
##### I have tried my best to document the project through folder organization, comments, and actual JSDocs where applicable. Unit tests and I have not agreed for a long time now, and I think it won't change anytime soon.
In the client side boilerplate, Typescript has been used to achieve a more structured and maintainable source code. ReactJS library which is one of the most important libraries for UI development alongside the other big names in the market, has been picked over to build the presentation layer of the application. Also for CSS, Less has been used to make CSS more functional.
### Less
This folder houses all the components, utils and libraries that make up ThreeTwo's UI
[Less](http://lesscss.org/) is a backwards-compatible language extension for CSS. Less helps to write CSS in a functional way and It's really easy to read and understand.
It is based on React 18, and uses:
### ESLint
1. _Redux_ 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
[ESLint](https://eslint.org/) is a pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript and Typescript.
[.eslintrc.json file](<(https://eslint.org/docs/user-guide/configuring)>) (alternatively configurations can be written in Javascript or YAML as well) is used describe the configurations required for ESLint. Below is the .eslintrc.json file which has been used.
```javascript
{
"extends": ["airbnb"],
"env": {
"browser": true,
"node": true
},
"rules": {
"no-console": "off",
"comma-dangle": "off",
"react/jsx-filename-extension": "off"
}
}
```
[Airbnb's Javascript Style Guide](https://github.com/airbnb/javascript) which has been used by the majority of JavaScript and Typescript developers worldwide. Since the aim is support for both client (browser) and server side (Node.js) source code, the **env** has been set to browser and node.
Optionally, you can override the current settings by installing `eslint` globally and running `eslint --init` to change the configurations to suit your needs. [**no-console**](https://eslint.org/docs/rules/no-console), [**comma-dangle**](https://eslint.org/docs/rules/comma-dangle) and [**react/jsx-filename-extension**](https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md) rules have been turned off.
### Webpack
[Webpack](https://webpack.js.org/) is a module bundler. Its main purpose is to capable Front-end developers to experience a modular programming style and bundle JavaScript and CSS files for usage in a browser.
[webpack.config.js](https://webpack.js.org/configuration/) file has been used to describe the configurations required for webpack. Below is the webpack.config.js file which has been used.
```javascript
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const outputDirectory = 'dist';
module.exports = {
entry: ['babel-polyfill', './src/client/index.tsx'],
output: {
path: path.join(__dirname, outputDirectory),
filename: './js/[name].bundle.js'
},
devtool: "source-map",
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.tsx?$/,
use:[
{
loader: "awesome-typescript-loader"
},
],
exclude: /node_modules/
},
{
enforce: "pre",
test: /\.js$/,
loader: "source-map-loader"
},
{
test: /\.less$/,
use: [
{ loader: 'style-loader' },
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: './Less',
hmr: process.env.NODE_ENV === 'development',
},
},
{ loader: 'css-loader' },
{
loader: 'less-loader',
options: {
strictMath: true,
noIeCompat: true,
}
},
]
},
{
test: /\.(png|woff|woff2|eot|ttf|svg)$/,
loader: 'url-loader?limit=100000'
},
]
},
resolve: {
extensions: ['*', '.ts', '.tsx', '.js', '.jsx', '.json', '.less']
},
devServer: {
port: 3000,
open: true,
proxy: {
'/api': 'http://localhost:8050'
}
},
plugins: [
new CleanWebpackPlugin([outputDirectory]),
new HtmlWebpackPlugin({
template: './public/index.html',
favicon: './public/favicon.ico',
title: "Book Manager",
}),
new MiniCssExtractPlugin({
filename: './css/[name].css',
chunkFilename: './css/[id].css',
}),
new CopyPlugin([
{ from: './src/client/Assets', to: 'assets' },
])
],
};
```
1. **entry:** entry: ./src/client/index.tsx is where the application starts executing and Webpack starts bundling.
Note: babel-polyfill is added to support async/await. Read more [here](https://babeljs.io/docs/en/babel-polyfill#usage-in-node-browserify-webpack).
2. **output path and filename:** the target directory and the filename for the bundled output.
3. **module loaders:** Module loaders are transformations that are applied on the source code of a module. We pass all the js file through [babel-loader](https://github.com/babel/babel-loader) to transform JSX to Javascript. CSS files are passed through [css-loaders](https://github.com/webpack-contrib/css-loader) and [style-loaders](https://github.com/webpack-contrib/style-loader) to load and bundle CSS files. Fonts and images are loaded through url-loader.
4. **Dev Server:** Configurations for the webpack-dev-server which will be described in coming section.
5. **plugins:** [clean-webpack-plugin](https://github.com/johnagan/clean-webpack-plugin) is a webpack plugin to remove the build directory before building. [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) simplifies creation of HTML files to serve your webpack bundles. It loads the template (public/index.html) and injects the output bundle.
### Webpack dev server
[Webpack dev server](https://webpack.js.org/configuration/dev-server/) is used along with webpack. It provides a development server that enables live reloading for the client side code changes.
The devServer section of webpack.config.js contains the configuration required to run webpack-dev-server which is given below.
```javascript
devServer: {
port: 3000,
open: true,
proxy: {
"/api": "http://localhost:8050"
}
}
```
[**Port**](https://webpack.js.org/configuration/dev-server/#devserver-port) specifies the Webpack dev server to listen on this particular port (3000 in this case). When [**open**](https://webpack.js.org/configuration/dev-server/#devserver-open) is set to true, it will automatically open the home page on start-up. [Proxying](https://webpack.js.org/configuration/dev-server/#devserver-proxy) URLs can be useful when you have a separate API backend development server, and you want to send API requests on the same domain.

View File

@@ -0,0 +1,178 @@
import {
SearchQuery,
SearchInstance,
PriorityEnum,
SearchResponse,
} from "threetwo-ui-typings";
import {
LIBRARY_SERVICE_BASE_URI,
SEARCH_SERVICE_BASE_URI,
} from "../constants/endpoints";
import {
AIRDCPP_SEARCH_RESULTS_ADDED,
AIRDCPP_SEARCH_RESULTS_UPDATED,
AIRDCPP_HUB_SEARCHES_SENT,
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
AIRDCPP_BUNDLES_FETCHED,
AIRDCPP_SEARCH_IN_PROGRESS,
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
LS_SINGLE_IMPORT,
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
AIRDCPP_TRANSFERS_FETCHED,
LIBRARY_ISSUE_BUNDLES,
AIRDCPP_SOCKET_CONNECTED,
AIRDCPP_SOCKET_DISCONNECTED,
} from "../constants/action-types";
import { isNil } from "lodash";
import axios from "axios";
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const toggleAirDCPPSocketConnectionStatus =
(status: String, payload?: any) => async (dispatch) => {
switch (status) {
case "connected":
dispatch({
type: AIRDCPP_SOCKET_CONNECTED,
data: payload,
});
break;
case "disconnected":
dispatch({
type: AIRDCPP_SOCKET_DISCONNECTED,
data: payload,
});
break;
default:
console.log("Can't set AirDC++ socket status.");
break;
}
};
export const downloadAirDCPPItem =
(
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
ADCPPSocket: any,
credentials: any,
): void =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
);
if (!isNil(downloadResult)) {
bundleDBImportResult = await axios({
method: "POST",
url: `${LIBRARY_SERVICE_BASE_URI}/applyAirDCPPDownloadMetadata`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
},
});
dispatch({
type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
downloadResult,
bundleDBImportResult,
});
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
comicBookDetail: bundleDBImportResult.data,
IMS_inProgress: false,
});
}
} catch (error) {
throw error;
}
};
export const getBundlesForComic =
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
const comicObject = await axios({
method: "POST",
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
id: `${comicObjectId}`,
},
});
// get only the bundles applicable for the comic
if (comicObject.data.acquisition.directconnect) {
const filteredBundles =
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
},
);
dispatch({
type: AIRDCPP_BUNDLES_FETCHED,
bundles: await Promise.all(filteredBundles),
});
}
} catch (error) {
throw error;
}
};
export const getTransfers =
(ADCPPSocket: any, credentials: any) => async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
const bundles = await ADCPPSocket.get("queue/bundles/1/85", {});
if (!isNil(bundles)) {
dispatch({
type: AIRDCPP_TRANSFERS_FETCHED,
bundles,
});
const bundleIds = bundles.map((bundle) => bundle.id);
// get issues with matching bundleIds
const issue_bundles = await axios({
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,
method: "POST",
data: { bundleIds },
});
dispatch({
type: LIBRARY_ISSUE_BUNDLES,
issue_bundles,
});
}
} catch (err) {
throw err;
}
};

View File

@@ -1,18 +1,51 @@
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import qs from "qs";
import { setupCache } from "axios-cache-interceptor";
import {
CV_SEARCH_SUCCESS,
CV_API_CALL_IN_PROGRESS,
CV_API_GENERIC_FAILURE,
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
CV_CLEANUP,
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
CV_WEEKLY_PULLLIST_FETCHED,
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
LIBRARY_STATISTICS_FETCHED,
} from "../constants/action-types";
import { COMICBOOKINFO_SERVICE_URI } from "../constants/endpoints";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../constants/endpoints";
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 cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: options,
}).then((response) => {
dispatch({
type: CV_WEEKLY_PULLLIST_FETCHED,
data: response.data.result,
});
});
} catch (error) {
console.log(error);
}
};
export const comicinfoAPICall = (options) => async (dispatch) => {
try {
@@ -20,7 +53,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
type: CV_API_CALL_IN_PROGRESS,
inProgress: true,
});
const serviceURI = COMICBOOKINFO_SERVICE_URI + options.callURIAction;
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
const response = await http(serviceURI, {
method: options.callMethod,
params: options.callParams,
@@ -29,16 +62,13 @@ 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) {
case "search":
dispatch({
type: CV_SEARCH_SUCCESS,
result: response.data,
searchResults: response.data,
});
break;
@@ -53,3 +83,127 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
});
}
};
export const getIssuesForSeries =
(comicObjectID: string) => async (dispatch) => {
dispatch({
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
});
dispatch({
type: CV_CLEANUP,
});
const issues = await axios({
url: `${COMICVINE_SERVICE_URI}/getIssuesForSeries`,
method: "POST",
params: {
comicObjectID,
},
});
console.log(issues);
dispatch({
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
issues: issues.data.results,
});
};
export const analyzeLibrary = (issues) => async (dispatch) => {
dispatch({
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
});
const queryObjects = issues.map((issue) => {
const { id, name, issue_number } = issue;
return {
issueId: id,
issueName: name,
volumeName: issue.volume.name,
issueNumber: issue_number,
};
});
const foo = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/findIssueForSeries`,
method: "POST",
data: {
queryObjects,
},
});
dispatch({
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
matches: foo.data,
});
};
export const getLibraryStatistics = () => async (dispatch) => {
dispatch({
type: LIBRARY_STATISTICS_CALL_IN_PROGRESS,
});
const result = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
method: "GET",
});
dispatch({
type: LIBRARY_STATISTICS_FETCHED,
data: result.data,
});
};
export const getComicBookDetailById =
(comicBookObjectId: string) => async (dispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,
});
const result = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicBookObjectId,
},
});
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
comicBookDetail: result.data,
IMS_inProgress: false,
});
};
export const getComicBooksDetailsByIds =
(comicBookObjectIds: Array<string>) => async (dispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,
});
const result = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooksByIds`,
method: "POST",
data: {
ids: comicBookObjectIds,
},
});
dispatch({
type: IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
comicBooks: result.data,
});
};
export const applyComicVineMatch =
(match, comicObjectId) => async (dispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,
});
const result = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
method: "POST",
data: {
match,
comicObjectId,
},
});
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
comicBookDetail: result.data,
IMS_inProgress: false,
});
};

View File

@@ -1,22 +1,62 @@
import axios from "axios";
import { IFolderData, IExtractedComicBookCoverFile } from "threetwo-ui-typings";
import { API_BASE_URI, SOCKET_BASE_URI } from "../constants/endpoints";
import { io } from "socket.io-client";
import { IFolderData } from "threetwo-ui-typings";
import {
IMS_COMICBOOK_METADATA_FETCHED,
IMS_SOCKET_CONNECTION_CONNECTED,
COMICVINE_SERVICE_URI,
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
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_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED,
CV_API_CALL_IN_PROGRESS,
CV_SEARCH_SUCCESS,
CV_CLEANUP,
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
IMS_CV_METADATA_IMPORT_FAILED,
LS_IMPORT,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_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,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
WANTED_COMICS_FETCHED,
VOLUMES_FETCHED,
LIBRARY_SERVICE_HEALTH,
LS_SET_QUEUE_STATUS,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { refineQuery } from "../shared/utils/filenameparser.utils";
import { matchScorer } from "../shared/utils/searchmatchscorer.utils";
import { success } from "react-notification-system-redux";
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>>({
url: "http://localhost:3000/api/import/walkFolders",
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
method: "POST",
data: {
basePathToWalk: path,
@@ -31,131 +71,315 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
}
/**
* Fetches comic book covers along with some metadata
*
* using {@link Renderer}.
*
* Used by external plugins
*
* @param {Object} options
* @return {Promise<string>} HTML of the page
* @return the comic book metadata
*/
export const fetchComicBookMetadata = (options) => async (dispatch) => {
const extractionOptions = {
sourceFolder: options,
extractTarget: "cover",
targetExtractionFolder: "./userdata/covers",
extractionMode: "bulk",
paginationOptions: {
pageLimit: 25,
page: 1,
},
};
const walkedFolders = await walkFolder("./comics");
const socket = io(SOCKET_BASE_URI, {
reconnectionDelayMax: 10000,
export const fetchComicBookMetadata = () => async (dispatch) => {
dispatch({
type: LS_IMPORT_CALL_IN_PROGRESS,
});
socket.on("connect", () => {
console.log(`connect ${socket.id}`);
dispatch({
type: IMS_SOCKET_CONNECTION_CONNECTED,
socketConnected: true,
});
// dispatch(
// success({
// // uid: 'once-please', // you can specify your own uid if required
// title: "Import Started",
// message: `<span class="icon-text has-text-success"><i class="fas fa-plug"></i></span> Socket <span class="has-text-info">${socket.id}</span> connected. <strong>${walkedFolders.length}</strong> comics scanned.`,
// dismissible: "click",
// position: "tr",
// autoDismiss: 0,
// }),
// );
const sessionId = localStorage.getItem("sessionId");
dispatch({
type: LS_IMPORT,
});
socket.on("disconnect", () => {
console.log(`disconnect`);
});
socket.emit("importComicsInDB", {
action: "getComicCovers",
params: {
extractionOptions,
walkedFolders,
},
});
socket.on("comicBookCoverMetadata", (data: IExtractedComicBookCoverFile) => {
dispatch({
type: IMS_COMICBOOK_METADATA_FETCHED,
data,
dataTransferred: true,
});
await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
method: "POST",
data: { sessionId },
});
};
export const getRecentlyImportedComicBooks = (options) => async (dispatch) => {
const { paginationOptions } = options;
return axios
.request({
url: "http://localhost:3000/api/import/getRecentlyImportedComicBooks",
method: "POST",
data: {
paginationOptions,
},
})
.then((response) => {
export const getImportJobResultStatistics = () => async (dispatch) => {
const result = await axios.request({
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
method: "GET",
});
dispatch({
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
* @param options
**/
export const getComicBooks = (options) => async (dispatch) => {
const { paginationOptions, predicate, comicStatus } = options;
const response = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions,
predicate,
},
});
switch (comicStatus) {
case "recent":
dispatch({
type: IMS_RECENT_COMICS_FETCHED,
data: response.data,
});
});
break;
case "wanted":
dispatch({
type: IMS_WANTED_COMICS_FETCHED,
data: response.data.docs,
});
break;
default:
console.log("Unrecognized comic status.");
}
};
export const fetchComicVineMatches = (searchPayload) => (dispatch) => {
try {
const issueString = searchPayload.rawFileDetails.path.split("/").pop();
let seriesSearchQuery = {};
const issueSearchQuery = refineQuery(issueString);
if (searchPayload.rawFileDetails.containedIn !== "comics") {
seriesSearchQuery = refineQuery(
searchPayload.rawFileDetails.containedIn.split("/").pop(),
);
}
dispatch({
type: CV_API_CALL_IN_PROGRESS,
});
axios
.request({
url: "http://localhost:3080/api/comicvine/fetchseries",
method: "POST",
data: {
format: "json",
sort: "name%3Aasc",
query: issueSearchQuery.searchParams.searchTerms.name,
fieldList: "id",
limit: "10",
offset: "0",
resources: "issue",
/**
* Makes a call to library service to import the comic book metadata into the ThreeTwo data store.
* @returns Nothing.
* @param payload
*/
export const importToDB =
(sourceName: string, metadata?: any) => (dispatch) => {
try {
const comicBookMetadata = {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
},
transformResponse: [
(r) => {
const searchMatches = JSON.parse(r);
return matchScorer(searchMatches.results, {
issue: issueSearchQuery,
series: seriesSearchQuery,
});
},
],
})
.then((response) => {
dispatch({
type: CV_SEARCH_SUCCESS,
searchResults: response.data,
searchQueryObject: {
issue: issueSearchQuery,
series: seriesSearchQuery,
},
});
};
dispatch({
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
});
return axios
.request({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: comicBookMetadata,
// transformResponse: (r: string) => JSON.parse(r),
})
.then((response) => {
const { data } = response;
dispatch({
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
importResult: data,
});
});
} catch (error) {
dispatch({
type: IMS_CV_METADATA_IMPORT_FAILED,
importError: error,
});
}
};
/* return { issueSearchQuery, series: seriesSearchQuery.searchParams }; */
export const fetchVolumeGroups = () => async (dispatch) => {
try {
dispatch({
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
});
const response = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
});
dispatch({
type: IMS_COMIC_BOOK_GROUPS_FETCHED,
data: response.data,
});
} catch (error) {
console.log(error);
}
dispatch({
type: CV_CLEANUP,
});
};
export const fetchComicVineMatches =
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
console.log(issueSearchQuery);
try {
dispatch({
type: CV_API_CALL_IN_PROGRESS,
});
axios
.request({
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.rawFileDetails,
},
transformResponse: (r) => {
const matches = JSON.parse(r);
return matches;
// return sortBy(matches, (match) => -match.score);
},
})
.then((response) => {
let matches: any = [];
if (
!isNil(response.data.results) &&
response.data.results.length === 1
) {
matches = response.data.results;
} else {
matches = response.data.map((match) => match);
}
dispatch({
type: CV_SEARCH_SUCCESS,
searchResults: matches,
searchQueryObject: {
issue: issueSearchQuery,
series: seriesSearchQuery,
},
});
});
} catch (error) {
console.log(error);
}
dispatch({
type: CV_CLEANUP,
});
};
/**
* This method is a proxy to `uncompressFullArchive` which uncompresses complete `rar` or `zip` archives
* @param {string} path The path to the compressed archive
* @param {any} options Options object
* @returns {any}
*/
export const extractComicArchive =
(path: string, options: any): any =>
async (dispatch) => {
dispatch({
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
});
await axios({
method: "POST",
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: path,
options,
},
});
};
/**
* Description
* @param {any} query
* @param {any} options
* @returns {any}
*/
export const searchIssue = (query, options) => async (dispatch) => {
dispatch({
type: SS_SEARCH_IN_PROGRESS,
});
const response = await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: { ...query, ...options },
});
if (response.data.code === 404) {
dispatch({
type: SS_SEARCH_FAILED,
data: response.data,
});
}
switch (options.trigger) {
case "wantedComicsPage":
dispatch({
type: WANTED_COMICS_FETCHED,
data: response.data.hits,
});
break;
case "globalSearchBar":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
data: response.data.hits,
});
break;
case "libraryPage":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED,
data: response.data.hits,
});
break;
case "volumesPage":
dispatch({
type: VOLUMES_FETCHED,
data: response.data.hits,
});
break;
default:
break;
}
};
export const analyzeImage =
(imageFilePath: string | Buffer) => async (dispatch) => {
dispatch({
type: FILEOPS_STATE_RESET,
});
dispatch({
type: IMG_ANALYSIS_CALL_IN_PROGRESS,
});
const foo = await axios({
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
method: "POST",
data: {
imageFilePath,
},
});
dispatch({
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
result: foo.data,
});
};

View File

@@ -0,0 +1,28 @@
import axios from "axios";
import { isNil } from "lodash";
import { METRON_SERVICE_URI } from "../constants/endpoints";
export const fetchMetronResource = async (options) => {
const metronResourceResults = await axios.post(
`${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__,
value: result.id,
};
});
return {
options: results,
hasMore: !isNil(metronResourceResults.data.next),
additional: {
page: !isNil(metronResourceResults.data.next)
? options.query.page + 1
: null,
},
};
};

View File

@@ -0,0 +1,77 @@
import axios from "axios";
import {
SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
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 getSettings = (settingsKey?) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
method: "POST",
data: settingsKey,
});
{
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: result.data,
});
}
};
export const deleteSettings = () => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
method: "POST",
});
if (result.data.ok === 1) {
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: {},
});
}
};
export const flushDb = () => async (dispatch) => {
dispatch({
type: SETTINGS_CALL_IN_PROGRESS,
});
const flushDbResult = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/flushDb`,
method: "POST",
});
if (flushDbResult) {
dispatch({
type: SETTINGS_DB_FLUSH_SUCCESS,
data: flushDbResult.data,
});
}
};
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

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="160px"
height="160px"
viewBox="0 0 16 16"
version="1.1"
id="SVGRoot">
<defs
id="defs222">
<linearGradient
id="linearGradient5642">
<stop
style="stop-color:#483e37;stop-opacity:1"
offset="0"
id="stop5638" />
<stop
style="stop-color:#339cc7;stop-opacity:0;"
offset="1"
id="stop5640" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient5642"
id="linearGradient5658"
x1="13.811552"
y1="7.9124665"
x2="8.5506983"
y2="2.2998061"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(0.47081933,0.18167275)" />
<linearGradient
xlink:href="#linearGradient5642"
id="linearGradient5658-6"
x1="13.811552"
y1="7.9124665"
x2="8.5506983"
y2="2.2998061"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-5.8345778,0.9966572)" />
<linearGradient
xlink:href="#linearGradient5642"
id="linearGradient5658-6-2"
x1="13.811552"
y1="7.9124665"
x2="8.5506983"
y2="2.2998061"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-2.2875303,6.2704208)" />
</defs>
<metadata
id="metadata225">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1">
<circle
style="fill:#fd8635;fill-opacity:1;stroke-width:0.34364313;stroke:none;stroke-opacity:1"
id="path78-7"
cx="4.9123297"
cy="5.9923167"
r="4.75" />
<circle
style="opacity:0.61000001;fill:url(#linearGradient5658-6);fill-opacity:1;stroke-width:0.3436431"
id="path78-3-5-1"
cx="4.9046054"
cy="5.9923072"
r="4.75" />
</g>
<g
id="layer2">
<circle
style="fill:#fd8635;fill-opacity:1;stroke-width:0.34364313"
id="path78"
cx="8.4543009"
cy="11.261043"
r="4.75" />
<circle
style="opacity:0.61000001;fill:url(#linearGradient5658-6-2);fill-opacity:1;stroke-width:0.3436431"
id="path78-3-5-1-9"
cx="8.4516525"
cy="11.266071"
r="4.75" />
</g>
<g
id="layer3">
<circle
style="opacity:1;fill:#348fed;fill-opacity:1;stroke-width:0.3436431"
id="path78-3"
cx="11.211483"
cy="5.1752739"
r="4.75" />
<circle
style="opacity:0.61000001;fill:url(#linearGradient5658);fill-opacity:1;stroke-width:0.3436431"
id="path78-3-5"
cx="11.210003"
cy="5.1773238"
r="4.75" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,6 @@
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path id="Path" fill="#e1933c" fill-rule="evenodd" stroke="#393838" stroke-width="21" stroke-linecap="round" stroke-linejoin="round" d="M 215.786591 23.351563 C 215.786591 23.351563 98.801674 21.473022 81.803864 21.200073 C 21.811596 20.236725 23.297552 52.264709 22.462652 104.258026 C 21.073503 190.766876 22.587183 312.129272 22.115429 437.295349 C 21.974922 474.574432 57.495609 491.702881 94.239006 492.460449 C 172.222427 494.068359 287.278351 491.559753 287.278351 491.559753 C 287.278351 491.559753 361.530945 494.731964 361.601563 409.742523 C 361.689667 303.742554 361.407318 172.708862 361.407318 172.708862 L 215.786591 23.351563 L 215.786591 23.351563 Z"/>
<path id="Shape" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round" d="M 110 270 L 264 270 M 162 330 L 220 330"/>
<path id="Line" fill="none" stroke="#ffffff" stroke-width="33" stroke-linecap="round" stroke-linejoin="round" d="M 110 395 L 264 394.999084"/>
<path id="path1" fill="none" stroke="#393838" stroke-width="21" stroke-linecap="round" stroke-linejoin="round" d="M 207 23 L 208 175 L 349 175"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 2.4.3 -->
<svg width="624" height="561" viewBox="0 0 624 561" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<text id="LCG" xml:space="preserve"><tspan x="57" y="282" font-family="DIN Alternate" font-size="288" font-weight="700" fill="#ff4300" letter-spacing="-2.88" xml:space="preserve">L</tspan><tspan font-family="DIN Alternate" font-size="288" font-weight="700" fill="#ff4300" letter-spacing="11.52" xml:space="preserve">CG</tspan></text>
<path id="Rounded-Rectangle" fill="#ff4300" fill-rule="evenodd" stroke="none" d="M 96 322 C 84.954399 322 76 330.954407 76 342 L 76 346 C 76 357.045593 84.954399 366 96 366 L 193 366 C 204.045593 366 213 357.045593 213 346 L 213 342 C 213 330.954407 204.045593 322 193 322 Z"/>
<path id="Rounded-Rectangle-copy-2" fill="#ff4300" fill-rule="evenodd" stroke="none" d="M 425 322 C 413.954407 322 405 330.954407 405 342 L 405 346 C 405 357.045593 413.954407 366 425 366 L 522 366 C 533.045593 366 542 357.045593 542 346 L 542 342 C 542 330.954407 533.045593 322 522 322 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path id="Path" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 212 23 L 78 23 C 78 23 20 33 20 107 C 20 193.52002 25 440 25 440 C 25 440 20 494 98 494 C 176 494 291 490 291 490 C 291 490 365.293945 491.979614 364 407 C 362.386169 301.012268 360 170 360 170 L 212 23 L 212 23 Z"/>
<path id="Ellipse" fill="none" stroke="#ed230d" stroke-width="37" stroke-linecap="round" stroke-linejoin="round" d="M 266 320 C 266 279.13092 232.86908 246 192 246 C 151.13092 246 118 279.13092 118 320 C 118 360.86908 151.13092 394 192 394 C 232.86908 394 266 360.86908 266 320 Z"/>
<path id="Ellipse-copy" fill="none" stroke="#ed230d" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="0.1 16" stroke-dashoffset="0" d="M 223 320.5 C 223 303.103027 208.896973 289 191.5 289 C 174.103027 289 160 303.103027 160 320.5 C 160 337.896973 174.103027 352 191.5 352 C 208.896973 352 223 337.896973 223 320.5 Z"/>
<path id="path1" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 207 23 L 208 175 L 349 175"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,5 @@
<svg width="384" height="512" viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path id="Path" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="56 56" stroke-dashoffset="56" d="M 221 23 L 78 23 C 78 23 20 33 20 107 C 20 193.52002 25 440 25 440 C 25 440 20 494 98 494 C 176 494 291 490 291 490 C 291 490 365.293945 491.979614 364 407 C 362.386169 301.012268 360 170 360 170 L 212 23 L 221 23 Z"/>
<path id="Ellipse" fill="none" stroke="#ed230d" stroke-width="25" stroke-linecap="round" stroke-linejoin="round" d="M 266 340 C 266 299.13092 232.86908 266 192 266 C 151.13092 266 118 299.13092 118 340 C 118 380.86908 151.13092 414 192 414 C 232.86908 414 266 380.86908 266 340 Z"/>
<path id="path1" fill="none" stroke="#ed230d" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="56 56" stroke-dashoffset="56" d="M 181 3 L 181 173 L 269 174 L 305 175"/>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="104px" height="160px" viewBox="0 0 104 160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<!-- Generated by Pixelmator Pro 2.1.2 -->
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="41.41" y1="30.667" x2="78.01" y2="133.767">
<stop offset="0" stop-color="#ffecd2" stop-opacity="1"/>
<stop offset="1" stop-color="#fcb69f" stop-opacity="1"/>
</linearGradient>
</defs>
<path id="Path" d="M12.093 0.081 C5.414 0.081 0 5.465 0 12.105 L0 147.976 C0 154.617 5.414 160 12.093 160 L91.907 160 C98.586 160 104 154.617 104 147.976 L104 12.105 C104 5.465 98.586 0.081 91.907 0.081 Z" fill-opacity="1" fill="url(#linear-gradient)" stroke="none"/>
<defs>
<text id="string" transform="matrix(1.0 0.0 0.0 1.0 20.0 54.0)">
<tspan fill="#252422" y="18.0" x="17.492" font-size="18" font-family="Avenir-Oblique, Avenir" text-decoration="none">NO </tspan>
<tspan fill="#252422" y="43.0" x="1.823000000000004" font-size="18" font-family="Avenir-Oblique, Avenir" text-decoration="none">COVER</tspan>
</text>
</defs>
<use id="NO-COVER" xlink:href="#string"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="112px" height="28px" viewBox="0 0 112 28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<!-- Generated by Pixelmator Pro 2.2 -->
<defs>
<image id="image" width="45px" height="28px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAcCAYAAAD1PDaSAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAALaADAAQAAAABAAAAHAAAAADa6ZRmAAALuElEQVRYCZVXCZRUxRWt5f9ep3u6Z9+gGRxwkEVcgIAKQURWAQFBRYMLGghqDKInajwuxwVQlAAalyNLICGI0QCiBkUQhIOETRlZRGBgNmZfenr/v37um9AcJAzqO+d2Vb+qX3Xr1atXrzi7uMgzzWY73WzQa0AEsNrp056avqPvaQ4OKCAOJICLjkWd25M8NMwBaOCXgG+Ac+Uy/HkW8AJvAh8CFxMdjT6gH3ClEKzQqUnH8GJXdmVQtTa2GrGKYCISjKqtaF8FhIALSnukaYK/2YQeTBHO5gajpTv+3wo0nhnFgfJ94KTfpZc0hhPUNh6oB84XWvQQwcTYPEemx6NcCU9W9YkFE7P75HhEEWdc55y3wrgimrCsQQvLd1WHYjF88wgQPX8w+i8upITOqXGZWlL4YU5Jp/evcUsHuQeRSgqR1vbN6lSzd3ZgqschqX1ysvGcsgBkX52eNn7WtsCyjJ0dVnbcNHqqsWZa7m15qeJSELYsi9dwodxCWE6XnbOvZ3Xwee2C3GPMOeP8qNoeae6QTsvN7JcIJr193D1O4qvrgWR/4bJJ7kvRbsFkmeMv91agbRRAO5SUgToTL7ySPcv2dNqMjp31vN665CrabXV2XciMD3mjYkf3Ocd3jllSXnao0ggxxVuxAieXVu+nbswko/RMDnR+mSRxvp45hc4twXSLWbYpKaPpcGQCtNUkpuAK5uBtB3T6AE8DdOTb1IeklxTyvqUdnz90m2fYYJwzOmyxFU2fbOnx8vd1feeXfnmsNvpWOKFmllRG5o57t+xrw+KfoU8bn7/uagqiXglcUOgEX0hU2IwoSynwknygo3fyhKegM/kZLUJG40p3wyZZKbaeTl0akYR5E/TLgfu3Fy7xBnj+NPiqVWXWbxhT+YCrMlabgxDxBtr3AQaQLwV7qCBdfi6FVYQYYjVGrG3f1cZo8a8AF5SkpdPQSj6UcaZXPGzG4hVmTRn9T+HuK2xcI6Ijk+1Rg0UOnI62covVaILl+t0Sh4l1Aa6f4h+ZCcI9qO+S1nVvX3licmp5rHYTCD8E1X8AIlykS/HSxumdem76XafH4d5TLMWDAxaUJpRiX6G9XUsTabLe4rxc+8w0v7YC9SuAONxiz6iKB8u5YCGN8bRh3gE0yHCArG4YprVv6t9P12OCJljTlumSbugbdCZzHvPfTeGSHTFL9z55elEvVBcBqwGKChSxrgWe+fCe/JyiTJnKLGVHZObT19RsDscUxfylQLuxmkhTvE1ZuTBPLpmfW2yzsT/hvwdY0pBoTouo+GnFLflc2swAdOmAHSDZmCK5H3N1trgFItwJXZUmtOJUkeIJq5g+sfxRujAoxu8BiAS5492Y9IHFk3IP9syzXUIuga9Pzt3UtOXTQy1kwKcBWly7QoPUp3pt2CkV8PuFVZDvDB8/EZkK/VuwdmhtcEvTZM/Qwgzhz3ZKe03EjNEFEQaOJDAfLjKFX5AmTqwhroxvHqqdV7AjvJfVJZrnQneQGiAUER7ukePovequPLfXLqdwzkyci3d6zDvhjxuKDPJHADt3cSFLV4TCiVhrxJIC8Wve45n50A0AaBt3fGscLUKVa5w7M0QaWY5chCSGDogeoi2ChBKKypDJzI3rmjcLEH4b/5O3KBF+wmUXndben9fFa+eXMa7slU3G211fPFYAwtT+OJC8vFBtX4h01DCsT+csajyplOIZGSLg80hyAboF120N7TEQP1HlYrJvaDMqNwDk15ZpwcxYEOptNkdxEqgFaKe+BEjIbebpgqXsebhzpSa4j8wRiqiyAQtP0PmpBp4AGoCfJUSaZM2O3a3xeNzSpOTsrtt9ZLV7gfrSaFnU5GYLdbpc77YLBUUYIsIMkMYhgssLqyFixqFKTkw+SSslCz7jtWtG1SSb3yfiV+G2rjtVnzje57VTtVjxerSTC13wuob+gpIk3WCa1rdrN4YqyNo3DXFfAvJ5+MJugFWN2VBPXw9KufJ6FGRZOrw8GjdVXLG96GLSeYKO3CcpdDvOtgvhOzlR9LNr5kD7yYpTRS8eWzro9VP1obj5Z7SvBtrcK/nRzymTpGnCFStWN4UQCJSuc29qikaWGwyULWtZ13Y4dCW7FtiyqD4BkBKuYZdWocUF1WksGoeE6ve5pCiqmKzpGjdzDMW0sZv09ERCdYGFZ6N9O5Dsj+pPSpLr2VyCvjje3GqqkqPxDywh+MRxqaXQjQW2rGzagCjzv/HnZ82msNQTKPC5JVxUFKDODEVh76yMyLTzG46PlwGnsK6LGNw594C9alt1dBN6kP+Wn+358yoUWWih4HHmrqcKhMJY5fy/NCBGK3b7aMfVOGKos5agCnM4bg116uPoTiRp6wfhNjSYaPMIhRuSKrSybria7ywZpwe8dit3Y5X22a8/ZrvnHAjPQNsS4KIxGO0XEjpDU4C23OasyaGgSTfU1SW6wGxcaDKQk2U/Ct1tOXqmXSCwwI01h2Vz6hwXN2MjnHZRSukl6uToBgqyxMznervqXbrpCxvaBzd/EXEdbDGegZ7yjV/iDuh+VogsGXAoac4lTf83h0JKxhMiRi6y8Lks2vKOIBaGy+bgsEnBWforWbPqkGs4N07v0JZfcDoIvG3RTpsQqTOK47/iTGvq+M/EcXxPZyAZr1H9RULzE+EHXS5+BK+dvqij+LE0IijXbfk6fJrU2VlixK2j/M9+1GFhORGGmdJx+YlJef377PvDpalOnVGiBftxPD+QETLmLvZrSGq5N5zgi8MJ8zrolgI/FSEo7pPL0f3gAnzAtcDzwKKbR/kd65YFCvv29hbjv7vNsVEhoVV1Br7fviva6caBLs6F0GdM877qXd5Sa33jDyMrpi2KW9LIEabbb8lYMi4zPLtEMBZXHw3WoozHzWm7rB3oO4TGA84XIpgKXH0GBVIKIitMUymPW+c2OzPvmJSmjxysZzptMkCpwrHSCM3XI0maLD4dbjDCKey7du8Ph5Typ9M+0GGsGbfMHyh5KmQxQQdC8Raf4GG8LuxNcOg2C6OfJTJcrDXNnoDL8NhXFUYv9KWMjUJnUmyoDAJuQZB0pqSnm9Iyj465gR+dMNI1xoioLb40OQzxM4s+oLSCStwdylTW6aagSelxapJ0d7wJx34ReOe7gJbftduJ0YnmFqvan8aywNuKesu44YlFZdBJN6Mbea8hDt8YMvr9gCcgMy0okLjIbM0bg/vTUpkmrU7oWw+Qa9AuUlrwMIg4rhgzYfvw2Y+Mx+umID+ys1e3lldT8EqSzCvvIOtBcF2pMLg6WiMqNP3R2g3VNfEcxPhqtH2VJG1DHhwrkh2Gwtr5v0kdU/Le+s2lv52amoVtoa1k1XesCua/fo+NaUhBYVxZ3rXZ6IepQAgRmud6dNv2Ey2whO0A2q+5yq9nfxKJ0fVM308Ebknx+Tb//qNPx+hCPoIhaDEaZ4kq7JbHMkQdMoLUMF5Dy99r2fje2mbl9khnOGxF4TKn0PcNgM6amSRdGrHiWphFj6cwV/Zjaff07L/p4/rpd3oUogieg8qK5h/2Mc0yLBOZGMcDNKYXCGU7okTUCxfCfIgfyDXCnuKl7uChgVdnavmfVMZOSSkXCF3PvPfdFWVZhZ2GCsYLkbDgdZKo3bnqH1s/f3Ox7vPK8mDQNOCOStNYNBJRdA52QHcYJS2cwulZSZKm7K3u9orHw2sLFrQ4mC09YBZyUwXrwDkLAdywbI26kd1SpVWl9oKzSVhT8B+G72fF/xpIpJFbJ3SXa+LyCQt7TPz4aX7IWdvkzajtP+mFl/NzLrs0LKQ2jIIMrFa1fu7zu49s28ojTU0OzLurqUltR9loIq9NJNren3RntCtJ0rSSF3dHDz4ZZbE1dmUf0Dc3sEOKg/fRSUDiBy9RVvWtK/fkvTYzF9HNAY84LQ5ft10Vr+sHO9vjBtsvOY+0Ot1py8fPU93Gs/4dgs0hjzfdi9wZFxOuJ7wn/734tS3716/bhWFXAa3ARQmi/f/kv5XnqvnoFJszAAAAAElFTkSuQmCC"/>
</defs>
<use id="Background-Layer" xlink:href="#image" x="41px" y="0px" width="45px" height="28px"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -1,177 +1,15 @@
@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/solid.scss";
$bg-color: yellow;
$border-color: red;
@tailwind base;
@tailwind components;
@tailwind utilities;
.app {
font-family: helvetica, arial, sans-serif;
padding: 2em;
border: 5px solid $border-color;
@layer base {
@font-face {
font-family: "PP Object Sans Regular";
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
}
p {
background-color: $bg-color;
}
}
.navbar-item.is-mega {
position: static;
.is-mega-menu-title {
margin-bottom: 0;
padding: 0.375rem 1rem;
}
}
.min {
overflow: visible;
.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 {
max-width: 200px;
.truncate {
width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
img {
max-width: 200px;
}
}
.card-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
column-gap: 0.5em;
row-gap: 1.2em;
.card {
max-width: 200px;
margin: 0 0 15px 0;
.is-horizontal {
flex-direction: row;
display: flex;
flex-basis: 50ex;
flex-grow: 0;
flex-shrink: 1;
box-shadow: none;
.card-image {
align-self: center;
.image {
max-width: 60px;
img {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 0.25em;
border-bottom-left-radius: 0.25em;
}
}
}
.card-content {
align-self: center;
flex: 1;
padding-left: 1em;
padding-top: 0;
padding-bottom: 0;
font-size: 0.8em;
ul {
li.status {
margin-top: 10px;
}
}
.truncate {
width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.is-divider {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
}
}
}
.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;
}
}
// comicvine search results
.search-results-container {
margin: 15px 0 0 0;
overflow: hidden;
table {
width: 100%;
tbody tr:nth-child(odd) {
background: #f6f6f6;
border-radius: 5px;
}
}
.search-result {
display: flex;
flex-direction: row;
padding: 10px;
margin: 0 0 10px 0;
.cover-image {
border-radius: 5px;
}
.search-result-details {
width: 100%;
.score {
float: right;
}
margin-left: 10px;
}
}
}
// 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;
@font-face {
font-family: "Hasklig Regular";
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
}
}

View File

@@ -1,36 +1,15 @@
import * as React from "react";
import { hot } from "react-hot-loader";
import Dashboard from "./Dashboard";
import Import from "./Import";
import { ComicDetail } from "./ComicDetail";
import { Switch, Route } from "react-router";
import Navbar from "./Navbar";
import React, { ReactElement } from "react";
import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2";
import "../assets/scss/App.scss";
class App extends React.Component {
public render() {
return (
<div>
<Navbar />
<Switch>
<Route exact path="/">
<Dashboard />
</Route>
<Route path="/import">
<Import path={"./comics"} />
</Route>
<Route
path={"/comic/details/:comicObjectId"}
component={ComicDetail}
/>
</Switch>
</div>
);
}
}
export const App = (): ReactElement => {
return (
<>
<Navbar2 />
<Outlet />
</>
);
};
declare let module: Record<string, unknown>;
export default hot(module)(App);
export default App;

View File

@@ -1,146 +0,0 @@
import React, { useState, useEffect, useCallback, ReactElement } from "react";
import { useParams } from "react-router-dom";
import axios from "axios";
import Card from "./Card";
import MatchResult from "./MatchResult";
import ComicVineSearchForm from "./ComicVineSearchForm";
import { css } from "@emotion/react";
import PuffLoader from "react-spinners/PuffLoader";
import { isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { fetchComicVineMatches } from "../actions/fileops.actions";
import { Drawer, Divider } from "antd";
const prettyBytes = require("pretty-bytes");
import "antd/dist/antd.css";
import { useDispatch, useSelector } from "react-redux";
type ComicDetailProps = {};
export const ComicDetail = ({}: ComicDetailProps): ReactElement => {
const [page, setPage] = useState(1);
const [visible, setVisible] = useState(false);
const [comicDetail, setComicDetail] = useState<{
rawFileDetails: IExtractedComicBookCoverFile;
}>();
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 { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
axios
.request({
url: `http://localhost:3000/api/import/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
})
.then((response) => {
setComicDetail(response.data);
})
.catch((error) => console.log(error));
}, [page]);
const dispatch = useDispatch();
const openDrawerWithCVMatches = useCallback(() => {
setVisible(true);
dispatch(fetchComicVineMatches(comicDetail));
}, [dispatch, comicDetail]);
const onClose = () => {
setVisible(false);
};
return (
<section className="container">
{!isEmpty(comicDetail) && !isUndefined(comicDetail) && (
<>
<h1 className="title">{comicDetail.rawFileDetails.name}</h1>
<div className="columns">
<div className="column is-narrow">
<Card comicBookCoversMetadata={comicDetail.rawFileDetails} />
</div>
<div className="column">
<p>{comicDetail.rawFileDetails.containedIn}</p>
<p>{prettyBytes(comicDetail.rawFileDetails.fileSize)}</p>
<button className="button" onClick={openDrawerWithCVMatches}>
<span className="icon">
<i className="fas fa-magic"></i>
</span>
<span>Match on Comic Vine</span>
</button>
</div>
</div>
<Drawer
title="ComicVine Search Results"
placement="right"
width={640}
closable={false}
onClose={onClose}
visible={visible}
className="comic-vine-match-drawer"
>
{!isEmpty(comicVineSearchQueryObject) &&
!isUndefined(comicVineSearchQueryObject) ? (
<div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm />
<Divider />
<p className="is-size-6">Searching against:</p>
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<div className="tags has-addons">
<span className="tag">Title</span>
<span className="tag is-info">
{
comicVineSearchQueryObject.issue.searchParams
.searchTerms.name
}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-info">
{
comicVineSearchQueryObject.issue.searchParams
.searchTerms.number
}
</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="progress-indicator-container">
<div className="indicator">
<PuffLoader loading={comicVineAPICallProgress} />
</div>
</div>
)}
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<MatchResult matchData={comicVineSearchResults} />
)}
</div>
</Drawer>
</>
)}
</section>
);
};

View File

@@ -0,0 +1,503 @@
import React, { useCallback, ReactElement, useEffect, useState } from "react";
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
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 { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
interface IAcquisitionPanelProps {
query: any;
comicObjectId: any;
comicObject: any;
settings: any;
}
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
airDCPPDownloadTick,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
})),
);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
const { comicObjectId } = props;
const issueName = props.query.issue.name || "";
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
const queryClient = useQueryClient();
// Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that
// All the user has to do is hit "Search AirDC++"
useEffect(() => {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, []);
/**
* Method to perform a search via an AirDC++ websocket
* @param {SearchData} data - a SearchData query
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
*/
const search = async (data: SearchData, ADCPPSocket: any) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
const instance: SearchInstance = await ADCPPSocket.post("search");
setAirDCPPSearchStatus(true);
// 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)
setAirDCPPSearchResults((state) => [...state, groupedResult]);
},
instance.id,
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.result.id === groupedResult.result.id,
);
const updatedState = [...airDCPPSearchResults];
if (
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) {
updatedState[bundleToUpdateIndex] = groupedResult;
}
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
setAirDCPPSearchInstance(instance);
setAirDCPPSearchStatus(false);
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
};
/**
* Method to download a bundle associated with a search result from AirDC++
* @param {Number} searchInstanceId - description
* @param {String} resultId - description
* @param {String} comicObjectId - description
* @param {String} name - description
* @param {Number} size - description
* @param {any} type - description
* @param {any} ADCPPSocket - description
* @returns {void} - description
*/
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
ADCPPSocket: any,
): void => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
);
if (!isNil(downloadResult)) {
bundleDBImportResult = await axios({
method: "POST",
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
},
});
console.log(bundleDBImportResult?.data);
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
// dispatch({
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
// downloadResult,
// bundleDBImportResult,
// });
//
// dispatch({
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
// comicBookDetail: bundleDBImportResult.data,
// IMS_inProgress: false,
// });
}
} catch (error) {
throw error;
}
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (hub) => hub.hub_url),
priority: 5,
};
search(manualQuery, airDCPPSocketInstance);
};
return (
<>
<div className="mt-5">
{!isEmpty(airDCPPSocketInstance) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<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="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"
/>
<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>
</form>
)}
/>
) : (
<div className="">
<article className="">
<div className="">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings &gt; AirDC++ &gt; Connection</code>.
</div>
</article>
</div>
)}
</div>
{/* AirDC++ search instance details */}
{!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="mb-1">
{hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<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">
Type
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Slots
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
<tr
key={idx}
className={
!isNil(result.dupe)
? "bg-gray-100 dark:bg-gray-700"
: "w-fit text-sm"
}
>
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
<p className="mb-2">
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}
{ellipsize(result.name, 70)}
</p>
<dl>
<dd>
<div className="inline-flex flex-row gap-2">
{!isNil(result.dupe) ? (
<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--copy-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
Dupe
</span>
</span>
) : null}
{/* Nicks */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--user-rounded-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.users.user.nicks}
</span>
</span>
{/* Flags */}
{result.users.user.flags.map((flag, idx) => (
<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--tag-horizontal-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{flag}
</span>
</span>
))}
</div>
</dd>
</dl>
</td>
<td>
{/* Extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.type.str}
</span>
</span>
</td>
<td className="px-2">
{/* Slots */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.slots.total} slots; {result.slots.free} free
</span>
</span>
</td>
<td className="px-2">
<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={() =>
download(
airDCPPSearchInstance.id,
result.id,
comicObjectId,
result.name,
result.size,
result.type,
airDCPPSocketInstance,
)
}
>
<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>
) : (
<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 className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
</div>
</article>
</div>
)}
</div>
</>
);
};
export default AcquisitionPanel;

View File

@@ -0,0 +1,32 @@
import React, { ReactElement } from "react";
import Select from "react-select";
export const Menu = (props): ReactElement => {
const {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
} = props.configuration;
return (
<Select
components={{ Placeholder }}
placeholder={
<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}
onChange={handleActionSelection}
/>
);
};
export default Menu;

View File

@@ -0,0 +1,51 @@
import React from "react";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { map } from "lodash";
export const AirDCPPBundles = (props) => {
return (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead>
<tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Filename
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Size
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Time
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{map(props.data, (bundle) => (
<tr key={bundle.id} className="text-sm">
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="text-xs">{ellipsize(bundle.target, 88)}</span>
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{prettyBytes(bundle.size)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,48 @@
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 => {
const [value, setValue] = useState(null);
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
const loadData = useCallback((query, loadedOptions, { page }) => {
return fetchMetronResource({
method: "GET",
resource: props.metronResource,
query: {
name: query,
page,
},
});
}, []);
return (
<CreatableAsyncPaginate
SelectComponent={Creatable}
debounceTimeout={200}
isDisabled={isAddingInProgress}
value={props.value}
loadOptions={loadData}
placeholder={props.placeholder}
// onCreateOption={onCreateOption}
onChange={props.onChange}
// cacheUniqs={[cacheUniq]}
additional={{
page: 1,
}}
/>
);
};
AsyncSelectPaginate.propTypes = {
metronResource: PropTypes.string.isRequired,
placeholder: PropTypes.string,
value: PropTypes.object,
onChange: PropTypes.func,
};
export default AsyncSelectPaginate;

View File

@@ -0,0 +1,473 @@
import React, { useState, ReactElement, useCallback } from "react";
import { useParams } from "react-router-dom";
import Card from "../shared/Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
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 TorrentSearchPanel from "./TorrentSearchPanel";
import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select";
import { RootState } from "threetwo-ui-typings";
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 axios from "axios";
import { styled } from "styled-components";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { refineQuery } from "filename-parser";
type ComicDetailProps = {};
/**
* Component for displaying the metadata for a comic in greater detail.
*
* @component
* @example
* return (
* <ComicDetail/>
* )
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
data: {
_id,
rawFileDetails,
inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
updatedAt,
},
userSettings,
} = data;
const [page, setPage] = useState(1);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const [comicVineMatches, setComicVineMatches] = useState([]);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
// const dispatch = useDispatch();
const openModal = useCallback((filePath) => {
setIsOpen(true);
// dispatch(
// extractComicArchive(filePath, {
// type: "full",
// purpose: "reading",
// imageResizeOptions: {
// baseWidth: 1024,
// },
// }),
// );
}, []);
// overridden <SlidingPanel> with some styles
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
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>
<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,
}}
/>
</>
),
},
editComicBookMetadata: {
content: () => <EditMetadataPanel data={rawFileDetails} />,
},
};
// Actions
const fetchComicVineMatches = async (
searchPayload,
issueSearchQuery,
seriesSearchQuery,
) => {
try {
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
method: "POST",
data: {
format: "json",
// hack
query: issueSearchQuery.inferredIssueDetails.name
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim(),
limit: "100",
page: 1,
resources: "volume",
scorerConfiguration: {
searchParams: issueSearchQuery.inferredIssueDetails,
},
rawFileDetails: searchPayload,
},
transformResponse: (r) => {
const matches = JSON.parse(r);
return matches;
// return sortBy(matches, (match) => -match.score);
},
});
let matches: any = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match) => match);
}
const scoredMatches = matches.sort((a, b) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
console.log(err);
}
};
// Action event handlers
const openDrawerWithCVMatches = () => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
if (!isUndefined(rawFileDetails)) {
issueSearchQuery = refineQuery(rawFileDetails.name);
} else if (!isEmpty(comicvine)) {
issueSearchQuery = refineQuery(comicvine.name);
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
</div>
<div>Match on ComicVine</div>
</span>
);
const editLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
</div>
<div>Edit Metadata</div>
</span>
);
const deleteLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
</div>
<div>Delete Comic</div>
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
menu: (base) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base) => ({
...base,
color: "black",
}),
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}),
singleValue: (base) => ({
...base,
paddingTop: "0.4rem",
}),
control: (base) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
color: "black",
border: "1px solid rgb(156, 163, 175)",
}),
};
// check for the availability of CV metadata
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
locg,
});
// query for airdc++
const airDCPPQuery = {
issue: {
name: issueName,
},
};
// Tab content and header details
const tabGroup = [
{
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: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: (
<div key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
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.data} key={3} />,
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={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
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={_id} issueName={issueName} />,
shouldShow: true,
},
{
id: 6,
name: "Downloads",
icon: (
<>
{acquisition?.directconnect?.downloads?.length +
acquisition?.torrent.length}
</>
),
content:
!isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} />
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
),
shouldShow: true,
},
];
// 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 mx-auto">
<div className="section">
{!isNil(data) && !isEmpty(data) && (
<>
<div>
<div className="flex flex-row mt-5">
<Card
imageUrl={url}
orientation={"cover-only"}
hasDetails={false}
/>
{/* raw file details */}
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && (
<div className="grid">
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
}}
>
{/* action dropdown */}
<div className="mt-1 flex flex-row gap-2 w-full">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
configuration={{
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
</div>
</RawFileDetails>
{/* <Modal
style={{ content: { marginTop: "2rem" } }}
isOpen={modalIsOpen}
onAfterOpen={afterOpenModal}
onRequestClose={closeModal}
contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}}
/>
)}
</Modal> */}
</div>
)}
</div>
</div>
<TabControls
filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length}
/>
<StyledSlidingPanel
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</StyledSlidingPanel>
</>
)}
</div>
</section>
);
};
export default ComicDetail;

View File

@@ -0,0 +1,35 @@
import React, { ReactElement } from "react";
import { useParams } from "react-router-dom";
import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
export const ComicDetailContainer = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const {
data: comicBookDetailData,
isLoading,
isError,
} = useQuery({
queryKey: ["comicBookMetadata"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
);
};

View File

@@ -0,0 +1,119 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs";
import { isEmpty, isUndefined } from "lodash";
import Card from "../shared/Carda";
import { convert } from "html-to-text";
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (
<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={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
</div>
{/* Comicvine metadata */}
<div className="mt-2">
<div className="text-md">ComicVine Metadata</div>
<div className="text-sm">
Last scraped on{" "}
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</div>
<div className="text-sm">
ComicVine Issue ID
<span>{data.id}</span>
</div>
</div>
</div>
{/* Publisher details */}
<div className="ml-8">
Published by{" "}
<span>{data.volumeInformation.publisher.name}</span>
<div>
Total issues in this volume{" "}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="text-md text-slate-900 dark:text-slate-900">
{data.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div>
{data.issue_number && (
<div className="">
<span>Issue Number</span>
<span>{data.issue_number}</span>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
</span>
</div>
) : data.resource_type ? (
<div>
<span>Type</span>
<span>{data.resource_type}</span>
</div>
) : null}
</div>
</div>
</div>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</div>
</div>
</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

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

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

@@ -0,0 +1,34 @@
import prettyBytes from "pretty-bytes";
import React, { ReactElement } from "react";
export const DownloadProgressTick = (props): ReactElement => {
return (
<div>
<h4 className="is-size-5">{props.data.name}</h4>
<div>
<span className="is-size-4 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "}
</span>
<progress
className="progress is-small is-success"
value={props.data.downloaded_bytes}
max={props.data.size}
>
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
100}
%
</progress>
</div>
<div className="is-size-6 mt-1 mb-2">
<p>{prettyBytes(props.data.speed)} per second.</p>
Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)}
</div>
<div>{props.data.target}</div>
</div>
);
};
export default DownloadProgressTick;

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useContext, ReactElement, useState } from "react";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles";
import { TorrentDownloads } from "./TorrentDownloads";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
interface IDownloadsPanelProps {
key: number;
}
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [bundles, setBundles] = useState([]);
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState([]);
const [activeTab, setActiveTab] = useState("torrents");
const { airDCPPSocketInstance, socketIOInstance } = useStore(
useShallow((state: any) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
socketIOInstance: state.socketIOInstance,
})),
);
// React to torrent progress data sent over websockets
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
const torrents = data.torrents
.flatMap(({ _id, details }) => {
if (_id === comicObjectId) {
return details;
}
})
.filter((item) => item !== undefined);
setTorrentDetails(torrents);
});
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
const { data: comicObject, isSuccess } = useQuery({
queryKey: ["bundles"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
id: `${comicObjectId}`,
},
}),
});
const getBundles = async (comicObject) => {
if (comicObject?.data.acquisition.directconnect) {
const filteredBundles =
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
},
);
return await Promise.all(filteredBundles);
}
};
// Call the scheduled job for fetching torrent data
// triggered by the active tab been set to "torrents"
const { data: torrentData } = useQuery({
queryFn: () =>
axios({
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
method: "GET",
params: {
trigger: activeTab,
},
}),
queryKey: [activeTab],
});
useEffect(() => {
getBundles(comicObject).then((result) => {
setBundles(result);
});
}, [comicObject]);
return (
<div className="columns is-multiline">
{!isEmpty(airDCPPSocketInstance) &&
!isEmpty(bundles) &&
activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
<div>
<div className="sm:hidden">
<label htmlFor="Download Type" className="sr-only">
Download Type
</label>
<select id="Tab" className="w-full rounded-md border-gray-200">
<option>DC++ Downloads</option>
<option>Torrents</option>
</select>
</div>
<div className="hidden sm:block">
<nav className="flex gap-6" aria-label="Tabs">
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "directconnect"
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
aria-current="page"
onClick={() => setActiveTab("directconnect")}
>
DC++ Downloads
</a>
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "torrents"
? "bg-slate-200 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
onClick={() => setActiveTab("torrents")}
>
Torrents
</a>
</nav>
</div>
</div>
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
</div>
);
};
export default DownloadsPanel;

View File

@@ -0,0 +1,313 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { Form, Field } 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 () => {};
const { data } = props;
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,
// );
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
mutators={{
...arrayMutators,
}}
render={({
handleSubmit,
form: {
mutators: { push, pop },
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Issue Details</label>
</div>
<div className="field-body">
<Field
name="issue_name"
component="input"
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"}
/>
</div>
</div>
{/* Issue Number and year */}
<div className="mt-4 flex flex-row gap-2">
<div>
<div className="text-sm">Issue Number</div>
<Field
name="issue_number"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Issue Number"
/>
<p className="text-xs">Do not enter the first zero</p>
</div>
<div>
{/* year */}
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
/>
</div>
<div>
<div className="text-sm">Page Count</div>
<Field
name="page_count"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Page Count"
/>
</div>
</div>
{/* page count */}
{/* Description */}
<div className="mt-2">
<label className="text-sm">Description</label>
<Field
name={"description"}
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"}
/>
</div>
<hr size="1" />
<div className="field is-horizontal">
<div className="field-label">
<label className="label">Distributor Info</label>
</div>
<div className="field-body">
<div className="field is-expanded">
<div className="field">
<p className="control has-icons-left">
<Field
name="distributor_sku"
component="input"
className="input"
placeholder="SKU"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-barcode"></i>
</span>
</p>
</div>
</div>
{/* UPC code */}
<div className="field">
<p className="control has-icons-left">
<Field
name="upc_code"
component="input"
className="input"
placeholder="UPC Code"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-box"></i>
</span>
</p>
</div>
</div>
</div>
<hr size="1" />
{/* Publisher */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Publisher</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"publisher"}
component={AsyncSelectPaginateAdapter}
placeholder={
<div>
<i className="fas fa-print mr-2"></i> Publisher
</div>
}
metronResource={"publisher"}
/>
</p>
</div>
</div>
</div>
{/* Arc */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Story Arc</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"story_arc"}
component={AsyncSelectPaginateAdapter}
placeholder={
<div>
<i className="fas fa-book-open mr-2"></i> Story Arc
</div>
}
metronResource={"arc"}
/>
</p>
</div>
</div>
</div>
{/* series */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Series</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"series"}
component={AsyncSelectPaginateAdapter}
placeholder={
<div>
<i className="fas fa-layer-group mr-2"></i> Series
</div>
}
metronResource={"series"}
/>
</p>
</div>
</div>
</div>
<hr size="1" />
{/* team credits */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Team Credits</label>
</div>
<div className="field-body mt-4">
<div className="field">
<div className="buttons">
<button
type="button"
className="button is-small"
onClick={() => push("credits", undefined)}
>
Add credit
</button>
<button
type="button"
className="button is-small"
onClick={() => pop("credits")}
>
Remove credit
</button>
</div>
</div>
</div>
</div>
<FieldArray name="credits">
{({ fields }) =>
fields.map((name, index) => (
<div className="field is-horizontal" key={name}>
<div className="field-label is-normal">
<label></label>
</div>
<div className="field-body">
<div className="field">
<p className="control">
<Field
name={`${name}.creator`}
component={AsyncSelectPaginateAdapter}
placeholder={
<div>
<i className="fa-solid fa-ghost"></i> Creator
</div>
}
metronResource={"creator"}
/>
</p>
</div>
<div className="field">
<p className="control">
<Field
name={`${name}.role`}
metronResource={"role"}
placeholder={
<div>
<i className="fa-solid fa-key"></i> Role
</div>
}
component={AsyncSelectPaginateAdapter}
/>
</p>
</div>
<span
className="icon is-danger mt-2"
onClick={() => fields.remove(index)}
style={{ cursor: "pointer" }}
>
<i className="fas fa-times"></i>
</span>
</div>
</div>
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>
</>
);
};
export default EditMetadataPanel;

View File

@@ -0,0 +1,137 @@
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";
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 applyCVMatch = async (match, comicObjectId) => {
return await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
method: "POST",
data: {
match,
comicObjectId,
},
});
};
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

@@ -0,0 +1,127 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns";
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data;
return (
<>
<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>
</div>
<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">
{inferredMetadata.issue.number}
</span>
) : null}
</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">
{prettyBytes(rawFileDetails.fileSize)}
</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">
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 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,
mimeType: PropTypes.string,
cover: PropTypes.shape({
filePath: PropTypes.string,
}),
}),
inferredMetadata: PropTypes.shape({
issue: PropTypes.shape({
year: PropTypes.string,
name: PropTypes.string,
number: PropTypes.number,
subtitle: PropTypes.string,
}),
}),
created_at: PropTypes.string,
updated_at: PropTypes.string,
}),
children: PropTypes.any,
};

View File

@@ -0,0 +1,53 @@
import React, { ReactElement, useState } from "react";
import { isNil } from "lodash";
export const TabControls = (props): ReactElement => {
const { filteredTabs, downloadCount } = props;
const [active, setActive] = useState(filteredTabs[0].id);
return (
<>
<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 }) => (
<a
key={id}
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
active === id
? "border-b border-cyan-50 dark:text-slate-200"
: "border-b border-transparent"
}`}
aria-current="page"
onClick={() => setActive(id)}
>
{/* Downloads tab and count badge */}
<>
{id === 6 && !isNil(downloadCount) ? (
<span className="inline-flex flex-row">
{/* download count */}
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
<span className="text-md text-slate-500 dark:text-slate-900">
{icon}
</span>
</span>
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
</span>
) : (
<span className="w-5 h-5">{icon}</span>
)}
{name}
</>
</a>
))}
</nav>
</div>
</div>
{filteredTabs.map(({ id, content }) => {
return active === id ? content : null;
})}
</>
);
};
export default TabControls;

View File

@@ -0,0 +1,246 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash";
import SlidingPane from "react-sliding-pane";
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 => {
const { data } = props;
const { socketIOInstance } = useStore(
useShallow((state) => ({
socketIOInstance: state.socketIOInstance,
})),
);
const queryClient = useQueryClient();
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
const [currentImage, setCurrentImage] = useState([]);
const [uncompressedArchive, setUncompressedArchive] = useState([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false);
const constructImagePaths = (data): Array<string> => {
return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
);
};
// Listen to the uncompression complete event and orchestrate the final payload
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
});
useEffect(() => {
let isMounted = true;
if (data.rawFileDetails?.archive?.uncompressed) {
const fetchUncompressedArchive = async () => {
try {
const response = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
method: "POST",
data: {
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
},
transformResponse: async (responseData) => {
const parsedData = JSON.parse(responseData);
const paths = parsedData.map((pathObject) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
});
const uncompressedArchive = constructImagePaths(paths);
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
}
},
});
} catch (error) {
console.error("Error fetching uncompressed archive:", error);
// Handle error if necessary
}
};
fetchUncompressedArchive();
}
// Cleanup function
return () => {
isMounted = false;
setUncompressedArchive([]);
};
}, [data]);
const analyzeImage = async (imageFilePath: string) => {
const response = await axios({
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
method: "POST",
data: {
imageFilePath,
},
});
setImageAnalysisResult(response?.data);
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
};
const {
data: uncompressionResult,
refetch,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
method: "POST",
url: `http://localhost:3000/api/library/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: data.rawFileDetails.filePath,
comicObjectId: data._id,
options: {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
},
},
}),
queryKey: ["uncompressedArchive"],
enabled: false,
});
if (isSuccess && shouldRefetchComicBookData) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
setShouldRefetchComicBookData(false);
}
// sliding panel init
const contentForSlidingPanel = {
imageAnalysis: {
content: () => {
return (
<div>
<pre className="text-sm">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre>
</div>
);
},
},
};
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath);
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<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"
>
<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">
{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={uncompressedArchive}
onClickHandler={openImageAnalysisPanel}
/>
) : null}
</div>
</div>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Image Analysis"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
);
};
export default ArchiveOperations;

View File

@@ -0,0 +1,66 @@
import { isUndefined } from "lodash";
import React, { ReactElement } from "react";
export const ComicInfoXML = (data): ReactElement => {
const { json } = data;
return (
<div className="flex md:w-4/5 lg:w-78">
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
<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>
</dd>
)}
<dd className="my-2">
{/* Genre */}
<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>
</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>
)}
</dd>
<dd>
{/* Notes */}
<span className="text-sm text-slate-500 dark:text-slate-900">
{json.notes[0]}
</span>
</dd>
</dl>
</div>
);
};
export default ComicInfoXML;

View File

@@ -0,0 +1,17 @@
import React, { ReactElement } from "react";
import ComicVineDetails from "../ComicVineDetails";
export const VolumeInformation = (props): ReactElement => {
const { data } = props;
return (
<div key={1}>
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
</div>
);
};
export default VolumeInformation;

View File

@@ -0,0 +1,77 @@
import React from "react";
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
export const TorrentDownloads = (props) => {
const { data } = props;
console.log(Object.values(data));
return (
<>
{data.map(({ torrent }) => {
return (
<dl className="mt-5 dark:text-slate-200 text-slate-600">
<dt className="text-lg">{torrent.name}</dt>
<p className="text-sm">{torrent.hash}</p>
<p className="text-sm">
Added on {dayjs.unix(torrent.added_on).format("ddd, D MMM, YYYY")}
</p>
<p className="flex gap-2 mt-1">
{torrent.progress > 0 ? (
<>
<progress
className="w-80 mt-2 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-slate-300 [&::-webkit-progress-value]:bg-green-400 [&::-moz-progress-bar]:bg-green-400 h-2"
value={Math.floor(torrent.progress * 100).toString()}
max="100"
></progress>
<span>{Math.floor(torrent.progress * 100)}%</span>
{/* downloaded/left */}
<p className="inline-flex items-center bg-slate-200 text-green-800 dark:text-green-900 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-slate-400">
<span className="pr-1">
<i className="icon-[solar--arrow-to-down-left-outline] h-4 w-4"></i>
</span>
<span className="text-md">
{prettyBytes(torrent.downloaded)}
</span>
{/* uploaded */}
<span className="pr-1 text-orange-800 dark:text-orange-900 ml-2">
<i className="icon-[solar--arrow-to-top-left-outline] h-4 w-4"></i>
</span>
<span className="text-md text-orange-800 dark:text-orange-900">
{prettyBytes(torrent.uploaded)}
</span>
</p>
</>
) : null}
</p>
<div className="flex gap-4 mt-2">
{/* Peers */}
<span className="inline-flex items-center bg-slate-200 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1">
<i className="icon-[solar--station-minimalistic-line-duotone] h-5 w-5"></i>
</span>
<span className="text-md text-slate-900">
{torrent.trackers_count}
</span>
</span>
{/* Size */}
<span className="inline-flex items-center bg-slate-200 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] h-4 w-4"></i>
</span>
<span className="text-md text-slate-900">
{prettyBytes(torrent.total_size)}
</span>
</span>
</div>
</dl>
);
})}
</>
);
};
export default TorrentDownloads;

View File

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

View File

@@ -1,104 +0,0 @@
import React from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
/**
* Component for accepting ComicVine search parameters
*
* @component
* @example
* const age = 21
* const name = 'Jitendra Nirnejak'
* return (
* <User age={age} name={name} />
* )
*/
export const ComicVineSearchForm = () => {
const onSubmit = () => {
return true;
};
const validate = () => {
return true;
};
const MyForm = () => (
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="field is-normal">
<label className="label">Issue Details</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 className="field">
<Field name="issueNumber">
{(props) => (
<p className="control is-expanded 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>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-info is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-hand-sparkles"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div>
</div>
</form>
)}
/>
);
return (
<Collapsible
trigger={"Match Manually"}
triggerTagName="a"
triggerClassName={"is-size-6"}
triggerOpenedClassName={"is-size-6"}
>
<MyForm />
</Collapsible>
);
};
export default ComicVineSearchForm;

View File

@@ -1,65 +0,0 @@
import * as React from "react";
import { connect } from "react-redux";
import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported";
import { getRecentlyImportedComicBooks } from "../actions/fileops.actions";
import { isEmpty } from "lodash";
interface IProps {
getRecentComics: Function;
recentComics: any;
}
interface IState {
fileOps: any;
}
class Dashboard extends React.Component<IProps, IState> {
componentDidMount() {
this.props.getRecentComics();
}
public render() {
return (
<div className="container">
<section className="section">
<h1 className="title">Dashboard</h1>
{!isEmpty(this.props.recentComics) &&
!isEmpty(this.props.recentComics.docs) ? (
<>
<h2 className="subtitle">Recently Imported</h2>
<RecentlyImported comicBookCovers={this.props.recentComics} />
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
}
/>
)}
</section>
</div>
);
}
}
function mapStateToProps(state: IState) {
return {
recentComics: state.fileOps.recentComics,
};
}
const mapDispatchToProps = (dispatch) => ({
getRecentComics() {
dispatch(
getRecentlyImportedComicBooks({
paginationOptions: {
page: 0,
limit: 18,
},
}),
);
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);

View File

@@ -0,0 +1,79 @@
import React, { ReactElement } from "react";
import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Dashboard = (): ReactElement => {
const { data: recentComics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
},
}),
queryKey: ["recentComics"],
});
const { data: wantedComics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
},
}),
queryKey: ["wantedComics"],
});
const { data: volumeGroups } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
}),
queryKey: ["volumeGroups"],
});
const { data: statistics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
method: "GET",
}),
queryKey: ["libraryStatistics"],
});
return (
<div className="container mx-auto max-w-full">
<PullList />
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
{/* Wanted comics */}
<WantedComicsList comics={wantedComics?.data?.docs} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics?.data} />}
{/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups?.data} />
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,103 @@
import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
export const LibraryStatistics = (
props: ILibraryStatisticsProps,
): ReactElement => {
const { stats } = props;
return (
<div className="mt-5">
<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">
<div className="flex flex-row gap-5">
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Library size</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</dd>
<dd>
<span className="text-2xl text-green-600">
{props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)}
</span>
</dd>
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics[0].issues.length}
</span>{" "}
tagged with ComicVine
</div>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "}
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
with ComicInfo.xml
</span>
</div>
)}
</div>
<div className="">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].fileTypes) &&
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
return (
<span
key={idx}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
>
{fileType.data.length} {fileType._id}
</span>
);
})}
</div>
{/* file types */}
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
{/* publisher with most issues */}
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && (
<>
<span className="">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id
}
</span>
{" has the most issues "}
<span className="">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count
}
</span>
</>
)}
</div>
</div>
</div>
</div>
);
};
export default LibraryStatistics;

View File

@@ -0,0 +1,167 @@
import React, { ReactElement, useState } from "react";
import { map } from "lodash";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import { Link } from "react-router-dom";
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import { setupCache } from "axios-cache-interceptor";
import { useQuery } from "@tanstack/react-query";
import "keen-slider/keen-slider.min.css";
import { useKeenSlider } from "keen-slider/react";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { Field, Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
type PullListProps = {
issues: any;
};
const http = rateLimiter(axios.create(), {
maxRequests: 1,
perMilliseconds: 1000,
maxRPS: 1,
});
const cachedAxios = setupCache(axios);
export const PullList = (): ReactElement => {
// datepicker
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "M-dd-yyyy"),
);
// keen slider
const [sliderRef, instanceRef] = useKeenSlider(
{
loop: true,
slides: {
origin: "auto",
number: 15,
perView: 5,
spacing: 15,
},
slideChanged() {
console.log("slide changed");
},
},
[
// add plugins here
],
);
const {
data: pullList,
refetch,
isSuccess,
isLoading,
isError,
} = useQuery({
queryFn: async (): any =>
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
}),
queryKey: ["pullList", inputValue],
});
const addToLibrary = (sourceName: string, locgMetadata) =>
importToDB(sourceName, { locg: locgMetadata });
const next = () => {
// sliderRef.slickNext();
};
const previous = () => {
// sliderRef.slickPrev();
};
return (
<>
<div className="content">
<Header
headerContent="Discover"
subHeaderContent={
<span className="text-md">
Pull List aggregated for the week from{" "}
<span className="underline">
<a href="https://leagueofcomicgeeks.com/comics/new-comics">
League Of Comic Geeks
</a>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
}
iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/"
/>
<div className="flex flex-row gap-5 mb-3">
{/* select week */}
<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>
</div>
{isSuccess && !isLoading && (
<div ref={sliderRef} className="keen-slider flex flex-row">
{map(pullList?.data.result, (issue, idx) => {
return (
<div key={idx} className="keen-slider__slide">
<Card
orientation={"vertical-2"}
imageUrl={issue.cover}
hasDetails
title={ellipsize(issue.name, 25)}
>
<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}
</span>
<div className="flex flex-row justify-end">
<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={() => addToLibrary("locg", issue)}
>
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want
</button>
</div>
</div>
</Card>
</div>
);
})}
</div>
)}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</>
);
};
export default PullList;

View File

@@ -0,0 +1,129 @@
import React, { ReactElement } from "react";
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 {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import Header from "../shared/Header";
type RecentlyImportedProps = {
comics: any;
};
export const RecentlyImported = (
comics: RecentlyImportedProps,
): ReactElement => {
return (
<div>
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{comics?.comics.map(
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(name, {
comicvine,
comicInfo,
locg,
});
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
return (
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline]"></i>
</span>
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
{/* 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 mt-1>
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
</div>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
);
},
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom";
import Card from "../shared/Carda";
import Header from "../shared/Header";
export const VolumeGroups = (props): ReactElement => {
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate();
const navigateToVolumes = (row) => {
navigate(`/volumes/all`);
};
return (
<section>
<Header
headerContent="Volumes"
subHeaderContent="Based on ComicVine Volume information"
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/volumes"}
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{map(deduplicatedGroups, (data) => {
return (
<div className="max-w-sm mx-auto" key={data._id}>
<Card
orientation="vertical-2"
key={data._id}
imageUrl={data.volumes.image.small_url}
hasDetails
>
<div className="py-3">
<div className="text-sm">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 48)}
</Link>
</div>
{/* issue count */}
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{data.volumes.count_of_issues} issues
</span>
</span>
</div>
</Card>
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
</div>
);
})}
</div>
</section>
);
};
export default VolumeGroups;

View File

@@ -0,0 +1,106 @@
import React, { ReactElement } from "react";
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 { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
type WantedComicsListProps = {
comics: any;
};
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
const navigate = useNavigate();
return (
<>
<Header
headerContent="Wanted Comics"
subHeaderContent="Comics marked as wanted from various sources"
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"}
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{map(
comics,
({
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical-2"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="pb-1">
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.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
}
</span>
</span>
</div>
) : null}
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-7 h-7"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div>
</Card>
);
},
)}
</div>
</>
);
};

View File

@@ -1,18 +1,18 @@
import * as React from "react";
interface ZeroStateProps {
header: string;
header: string;
message: string;
}
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
return (
<article className="message is-info">
<div className="message-body">
<p>{ props.header }</p>
{ props.message }
<article className="">
<div className="">
<p>{props.header}</p>
{props.message}
</div>
</article>
);
};
export default ZeroState;
export default ZeroState;

View File

@@ -0,0 +1,122 @@
import React, { ReactElement, useEffect, useState } from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { isEmpty, isNil, isUndefined } from "lodash";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel";
interface IDownloadsProps {
data: any;
}
export const Downloads = (props: IDownloadsProps): ReactElement => {
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { settings, socket },
} = airDCPPConfiguration;
// const dispatch = useDispatch();
// 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(() => {
if (!isUndefined(socket) && !isEmpty(settings)) {
dispatch(
getTransfers(socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
}
}, [socket]);
useEffect(() => {
if (!isUndefined(issueBundles)) {
const foo = issueBundles.data.map((bundle) => {
const {
rawFileDetails,
inferredMetadata,
acquisition: {
directconnect: { downloads },
},
sourcedMetadata: { locg, comicvine },
} = bundle;
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
locg,
});
return { ...bundle, issueName, url };
});
setBundles(foo);
}
}, [issueBundles]);
return !isNil(bundles) ? (
<div className="container">
<section className="section">
<h1 className="title">Downloads</h1>
<div className="columns">
<div className="column is-half">
{bundles.map((bundle, idx) => {
console.log(bundle);
return (
<div key={idx}>
<MetadataPanel
data={bundle}
imageStyle={{ maxWidth: 80 }}
titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }}
containerStyle={{
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>
</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>
);
},
)}
</tbody>
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</div>
);
})}
</div>
</div>
</section>
</div>
) : (
<div>There are no downloads.</div>
);
};
export default Downloads;

View File

@@ -0,0 +1,82 @@
import { debounce, isEmpty, map } from "lodash";
import React, { ReactElement, useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import Card from "../shared/Carda";
import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel";
interface ISearchBarProps {
data: any;
}
export const SearchBar = (data: ISearchBarProps): ReactElement => {
const dispatch = useDispatch();
const searchResults = useSelector(
(state: RootState) => state.fileOps.librarySearchResultsFormatted,
);
const performSearch = useCallback(
debounce((e) => {
dispatch(
searchIssue(
{
query: {
volumeName: e.target.value,
},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumeName",
trigger: "globalSearchBar",
},
),
);
}, 500),
[data],
);
return (
<>
<div className="control has-icons-right">
<input
className="input mt-2"
placeholder="Search Library"
onChange={(e) => performSearch(e)}
/>
<span className="icon is-right mt-2">
<i className="fa-solid fa-magnifying-glass"></i>
</span>
</div>
{!isEmpty(searchResults) ? (
<div
className="columns box is-multiline"
style={{
padding: 4,
position: "absolute",
width: 360,
margin: "60px 0 0 350px",
}}
>
{map(searchResults, (result, idx) => (
<MetadataPanel
data={result}
key={idx}
imageStyle={{ maxWidth: 70 }}
titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }}
containerStyle={{
width: "100vw",
padding: 0,
margin: "0 0 8px 0",
}}
/>
))}
</div>
) : null}
</>
);
};

View File

@@ -1,168 +0,0 @@
import * as React from "react";
import { isUndefined } from "lodash";
import { connect } from "react-redux";
import { fetchComicBookMetadata } from "../actions/fileops.actions";
import { IFolderData } from "threetwo-ui-typings";
import { io, Socket } from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints";
import DynamicList, { createCache } from "react-window-dynamic-list";
interface IProps {
matches: unknown;
fetchComicMetadata: any;
path: string;
covers: any;
}
interface IState {
folderWalkResults?: Array<IFolderData>;
searchPaneIndex: number;
fileOps: any;
}
let socket: Socket;
class Import extends React.Component<IProps, IState> {
/**
* 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
*/
constructor(props: IProps) {
super(props);
this.state = {
folderWalkResults: [],
searchPaneIndex: 0,
fileOps: [],
};
}
public toggleSearchResultsPane(paneId: number): void {
this.setState({
searchPaneIndex: paneId,
});
}
public initiateSocketConnection = () => {
if (typeof this.props.path !== "undefined") {
socket = io(SOCKET_BASE_URI, {
reconnectionDelayMax: 10000,
});
socket.on("connect", () => {
console.log(`connect ${socket.id}`);
});
this.props.fetchComicMetadata();
}
};
public cache = createCache();
public renderRow = ({ index, style }) => (
<div style={style} className="min is-size-7">
<span className="tag is-light">{index}</span>
<div className="tags has-addons">
<span className="tag is-success">cover</span>
<span className="tag is-success is-light has-text-weight-medium">
{this.props.covers[index].comicBookCoverMetadata.name}
</span>
</div>
imported from
<div className="tags has-addons">
<span className="tag is-success">path</span>
<span className="tag is-success is-light has-text-weight-medium">
{this.props.covers[index].comicBookCoverMetadata.path}
</span>
</div>
<div className="db-import-result-panel">
<pre className="has-background-success-light">
<span className="icon">
<i className="fas fa-database"></i>
</span>
{JSON.stringify(this.props.covers[index].dbImportResult, null, 2)}
</pre>
</div>
</div>
);
public render() {
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="button is-medium"
onClick={this.initiateSocketConnection}
>
<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>
{!isUndefined(this.state.folderWalkResults) ? (
<div>
<DynamicList
data={this.props.covers}
cache={this.cache}
height={1000}
width={"100%"}
>
{this.renderRow}
</DynamicList>
</div>
) : null}
</section>
</div>
);
}
}
function mapStateToProps(state: IState) {
console.log("state", state);
return {
// matches: state.comicInfo.searchResults,
covers: state.fileOps.comicBookMetadata,
};
}
const mapDispatchToProps = (dispatch, ownProps) => ({
fetchComicMetadata() {
dispatch(fetchComicBookMetadata(ownProps.path));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Import);
export { socket };

View File

@@ -0,0 +1,298 @@
import React, { ReactElement, useCallback, useEffect } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns";
import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
interface IProps {
matches?: unknown;
fetchComicMetadata?: any;
path: string;
covers?: any;
}
/**
* Component to facilitate the import of comics to the ThreeTwo library
*
* @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 queryClient = useQueryClient();
const { importJobQueue, socketIOInstance } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
socketIOInstance: state.socketIOInstance,
})),
);
const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({
mutationFn: async () =>
await axios.request({
url: `http://localhost:3000/api/library/newImport`,
method: "POST",
data: { sessionId },
}),
});
const { data, isError, isLoading } = useQuery({
queryKey: ["allImportJobResults"],
queryFn: async () =>
await axios({
method: "GET",
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
}),
});
const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
(data) => console.log(data),
);
};
/**
* Method to render import job queue pause/resume controls on the UI
*
* @param status The `string` status (either `"pause"` or `"resume"`)
* @returns ReactElement A `<button/>` that toggles queue status
* @remarks Sets the global `importJobQueue.status` state upon toggling
*/
const renderQueueControls = (status: string): ReactElement | null => {
switch (status) {
case "running":
return (
<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-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("pause", "paused");
importJobQueue.setStatus("paused");
}}
>
<span className="text-md">Pause</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button>
</div>
);
case "paused":
return (
<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-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
toggleQueue("resume", "running");
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Resume</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button>
</div>
);
case "drained":
return null;
default:
return null;
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
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>
<div className="my-4">
{importJobQueue.status === "drained" ||
(importJobQueue.status === undefined && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Start Import</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
))}
</div>
{/* Activity */}
{(importJobQueue.status === "running" ||
importJobQueue.status === "paused") && (
<>
<span className="flex items-center my-5 max-w-screen-lg">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Import Activity
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
{/* Successful import counts */}
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dd className="text-3xl text-green-600 md:text-5xl">
{importJobQueue.successfulJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
imported
</dt>
</div>
{/* Failed job counts */}
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
<dd className="text-3xl text-red-600 md:text-5xl">
{importJobQueue.failedJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
failed
</dt>
</div>
<div className="flex flex-col dark:text-slate-200 text-slate-400">
<dd>{renderQueueControls(importJobQueue.status)}</dd>
</div>
</dl>
</div>
<div className="flex">
<span className="mt-2 dark:text-slate-200 text-slate-400">
Imported: <span>{importJobQueue.mostRecentImport}</span>
</span>
</div>
</>
)}
{/* Past imports */}
{!isLoading && !isEmpty(data?.data) && (
<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">
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?.data.map((jobResult, id) => {
return (
<tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.completedJobs}
</p>
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
<span className="h-5 w-6">
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.failedJobs}
</p>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</section>
</div>
);
};
export default Import;

View File

@@ -0,0 +1,316 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
*/
export const Library = (): ReactElement => {
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
const [searchQuery, setSearchQuery] = useState({
query: {},
pagination: {
size: 25,
from: offset,
},
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/**
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
query,
pagination,
type,
},
});
};
const searchIssues = (e) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
};
const ComicInfoXML = (value) => {
return value.data ? (
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
{/* Series Name */}
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
{ellipsize(value.data.series[0], 45)}
</span>
</span>
<div className="flex flex-row mt-2 gap-2">
{/* Pages */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Pages: {value.data.pagecount[0]}
</span>
</span>
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{!isNil(value.data.number) && (
<span>{parseInt(value.data.number[0], 10)}</span>
)}
</span>
</span>
</div>
</dl>
) : null;
};
const columns = useMemo(
() => [
{
header: "Comic Metadata",
footer: 1,
columns: [
{
header: "File Details",
id: "fileDetails",
minWidth: 400,
accessorKey: "_source",
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : null,
},
],
},
{
header: "Additional Metadata",
columns: [
{
header: "Date of Import",
accessorKey: "_source.createdAt",
cell: (info) => {
return !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>
) : null;
},
},
{
header: "Downloads",
accessorKey: "_source.acquisition",
cell: (info) => (
<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">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
DC++: {info.getValue().directconnect.downloads.length}
</span>
</span>
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.length}
</span>
</span>
</div>
),
},
],
},
],
[],
);
/**
* 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 = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
}
};
/**
* 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 = (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",
});
// setOffset(from);
};
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{!isUndefined(searchResults?.hits) ? (
<div>
<div>
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
>
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div>
</div>
) : (
<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>
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
{!isUndefined(searchResults?.data?.meta?.body) ? (
<p>
{JSON.stringify(
searchResults?.data.meta.body.error.root_cause,
null,
4,
)}
</p>
) : null}
</pre>
</div>
</div>
)}
</section>
</div>
);
};
export default Library;

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect, useMemo, ReactElement } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router";
import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../../shared/utils/formatting.utils";
import { useTable, usePagination } from "react-table";
import prettyBytes from "pretty-bytes";
import ellipsize from "ellipsize";
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 "../shared/Carda";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
interface ILibraryGridProps {}
export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
const data = useSelector(
(state: RootState) => state.fileOps.recentComics.docs,
);
const pageTotal = useSelector(
(state: RootState) => state.fileOps.recentComics.totalDocs,
);
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 3,
500: 1,
};
return (
<section className="container">
<div className="section">
<h1 className="title">Library</h1>
<Masonry
breakpointCols={breakpointColumnsObj}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
{data.map(({ _id, rawFileDetails, sourcedMetadata }) => {
let imagePath = "";
let comicName = "";
if (!isEmpty(rawFileDetails.cover)) {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${removeLeadingPeriod(
rawFileDetails.cover.filePath,
)}`,
);
imagePath = escapePoundSymbol(encodedFilePath);
comicName = rawFileDetails.name;
} else if (!isNil(sourcedMetadata)) {
imagePath = sourcedMetadata.comicvine.image.small_url;
comicName = sourcedMetadata.comicvine.name;
}
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(comicName, 18)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical"}
imageUrl={imagePath}
hasDetails
title={comicName ? titleElement : null}
>
<div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && (
<span className="icon cv-icon is-small">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{isNil(rawFileDetails) && (
<span className="icon has-text-info">
<i className="fas fa-adjust" />
</span>
)}
{!isUndefined(sourcedMetadata.comicvine.volumeInformation) &&
!isEmpty(
detectIssueTypes(
sourcedMetadata.comicvine.volumeInformation.description,
),
) ? (
<span className="tag is-warning ml-1">
{
detectIssueTypes(
sourcedMetadata.comicvine.volumeInformation
.description,
).displayName
}
</span>
) : null}
</div>
</Card>
);
})}
</Masonry>
</div>
</section>
);
};
export default LibraryGrid;

View File

@@ -0,0 +1,48 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom";
export const SearchBar = (props): ReactElement => {
const { searchHandler } = props;
return (
<Form
onSubmit={searchHandler}
initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<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="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"
/>
</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>
);
}}
</Field>
</form>
)}
/>
);
};
export default SearchBar;

View File

@@ -0,0 +1,66 @@
import React, { ReactElement } from "react";
import PullList from "../PullList/PullList";
import { Volumes } from "../Volumes/Volumes";
import WantedComics from "../WantedComics/WantedComics";
import { Library } from "./Library";
interface ITabulatedContentContainerProps {
category: string;
}
/**
* Component to draw the contents of a category in a table.
*
* @component
* @example
* return (
* <TabulatedContentContainer category={"library"} />
* )
*/
const TabulatedContentContainer = (
props: ITabulatedContentContainerProps,
): ReactElement => {
const { category } = props;
const renderTabulatedContent = () => {
switch (category) {
case "library":
return <Library />;
case "pullList":
return <PullList />;
case "wanted":
return <WantedComics />;
case "volumes":
return <Volumes />;
default:
return <></>;
}
};
return renderTabulatedContent();
// : (
// <div className="container">
// <section className="section is-small">
// <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, and Elasticsearch doesn't have any
// indices. Try resetting the library from <code>Settings > Flush DB & Temporary Folders</code> and then import your library again.
// </div>
// </article>
// <pre>
// {!isUndefined(searchError.data) &&
// JSON.stringify(
// searchError.data.meta.body.error.root_cause,
// null,
// 4,
// )}
// </pre>
// </div>
// </div>
// </section>
// </div>
// );
};
export default TabulatedContentContainer;

View File

@@ -1,59 +0,0 @@
import React, { useEffect } from "react";
import { map } from "lodash";
interface MatchResultProps {
matchData: any;
}
export const MatchResult = (props: MatchResultProps) => {
return (
<>
<table>
<thead>
<tr>
<th></th>
</tr>
</thead>
<tbody>
{map(props.matchData, (match, idx) => {
return (
<tr className="search-result" key={idx}>
<td>
<img className="cover-image" src={match.image.thumb_url} />
</td>
<td className="search-result-details">
<div className="tag score is-primary is-medium is-pulled-right">
{parseFloat(match.score).toFixed(2)}
</div>
<div className="is-size-5">{match.name}</div>
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline">
<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">Type</span>
<span className="tag is-warning">
{match.resource_type}
</span>
</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</>
);
};
export default MatchResult;

View File

@@ -0,0 +1,127 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import T2Table from "../shared/T2Table";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
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,
// );
useEffect(() => {
// dispatch(
// getWeeklyPullList({
// startDate: "2023-7-28",
// pageSize: "15",
// currentPage: "1",
// }),
// );
}, []);
const nextPageHandler = () => {};
const previousPageHandler = () => {};
const columnData = useMemo(
() => [
{
header: "Comic Information",
columns: [
{
header: "Details",
id: "comicDetails",
minWidth: 450,
accessorKey: "issue",
cell: (row) => {
const item = row.getValue();
return (
<div className="columns">
<div className="column is-three-quarters">
<div className="comic-detail issue-metadata">
<dl>
<dd>
<div className="columns mt-2">
<div className="column is-3">
<Card
imageUrl={item.cover}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{item.name}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{item.publisher}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(item.description, 190)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
{item.price}
</span>
<span className="tag is-success is-light">
{item.pulls}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
</div>
</div>
);
},
},
],
},
],
[],
);
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Weekly Pull List</h1>
</div>
{!isNil(pullListComics) && (
<div>
<div className="library">
<T2Table
sourceData={pullListComics}
columns={columnData}
totalPages={pullListComics.length}
paginationHandlers={{
nextPage: nextPageHandler,
previousPage: previousPageHandler,
}}
/>
</div>
</div>
)}
</div>
</section>
);
};
export default PullList;

View File

@@ -1,23 +0,0 @@
import React from "react";
import Card from "./Card";
import { map } from "lodash";
type RecentlyImportedProps = {
comicBookCovers: any;
};
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps) => (
<section className="card-container">
{map(comicBookCovers.docs, (doc, idx) => {
return (
<Card
key={idx}
comicBookCoversMetadata={doc.rawFileDetails}
mongoObjId={doc._id}
/>
);
})}
</section>
);

View File

@@ -0,0 +1,243 @@
import React, { useCallback, ReactElement, useState } from "react";
import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import dayjs from "dayjs";
import { useQuery, 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 formData = {
search: "",
};
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [comicVineMetadata, setComicVineMetadata] = useState({});
const getCVSearchResults = (searchQuery) => {
setSearchQuery(searchQuery.search);
};
const {
data: comicVineSearchResults,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
}),
queryKey: ["comicvineSearchResults", searchQuery],
enabled: !isNil(searchQuery),
});
// add to library
const { data: additionResult } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata:
{ comicvine: comicVineMetadata?.comicData } || null,
acquisition: { source: { wanted: true, name: "comicvine" } },
},
},
}),
queryKey: ["additionResult"],
enabled: !isNil(comicVineMetadata.comicData),
});
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form
onSubmit={getCVSearchResults}
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>
</form>
)}
/>
</div>
{isLoading && <>Loading kaka...</>}
{!isNil(comicVineSearchResults?.data.results) &&
!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 isSuccess ? (
<div key={result.id} className="mb-5">
<div className="flex flex-row">
<div className="mr-5">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="column">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.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", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<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-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => addToLibrary("comicvine", result)}
>
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
Mark as Wanted
</button>
</div>
</div>
</div>
</div>
) : (
<div>Loading</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>
);
};

View File

@@ -0,0 +1,139 @@
import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form";
import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../../store";
import axios from "axios";
export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient();
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
} = useStore((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
}));
const {
data: settings,
isLoading,
isError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
let hubList = {};
if (!isNil(hubs)) {
hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: {
settingsPayload: values,
settingsObjectId: settings?.data._id,
settingsKey: "directConnect",
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
const validate = async () => {};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
return (
<>
{!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form
onSubmit={mutate}
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>
)}
/>
) : (
<>
<article className="message">
<div className="message-body">
No configured hubs detected in AirDC++. <br />
Configure to a hub in AirDC++ and then select a default hub here.
</div>
</article>
</>
)}
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
<>
<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>Default Hub For Searches:</h6>
{settings?.data.directConnect?.client.hubs.map(
({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</div>
</>
) : null}
</>
);
};
export default AirDCPPHubsForm;

View File

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

View File

@@ -0,0 +1,101 @@
import React, { ReactElement, useCallback } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { isUndefined, isEmpty } from "lodash";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
import { useShallow } from "zustand/react/shallow";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
export const AirDCPPSettingsForm = (): ReactElement => {
// cherry-picking selectors for:
// 1. initial values for the form
// 2. If initial values are present, get the socket information to display
const { setState } = useStore;
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPClientConfiguration,
airDCPPSocketInstance,
setAirDCPPSocketInstance,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPSocketInstance: state.airDCPPSocketInstance,
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
})),
);
/**
* Mutation to update settings and subsequently initialize
* AirDC++ socket with those settings
*/
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "directConnect" },
}),
onSuccess: async (values) => {
const {
data: {
directConnect: {
client: { host },
},
},
} = values;
const dcppSocketInstance = await initializeAirDCPPSocket(host);
setState({
airDCPPClientConfiguration: host,
airDCPPSocketInstance: dcppSocketInstance,
});
},
});
const deleteSettingsMutation = useMutation(
async () =>
await axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
// const removeSettings = useCallback(async () => {
// // airDCPPSettings.setSettings({});
// }, []);
//
const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration
: {};
return (
<>
<ConnectionForm
initialData={initFormData}
submitHandler={mutate}
formHeading={"Configure AirDC++"}
/>
{!isEmpty(airDCPPSessionInformation) ? (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
) : null}
{!isEmpty(airDCPPClientConfiguration) ? (
<p className="control mt-4">
as
<button
className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()}
>
Delete
</button>
</p>
) : null}
</>
);
};
export default AirDCPPSettingsForm;

View File

@@ -0,0 +1,62 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Form, Field } from "react-final-form";
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
import axios from "axios";
export const ProwlarrSettingsForm = (props) => {
const { data } = useQuery({
queryFn: async (): any => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
method: "POST",
data: {
host: "localhost",
port: "9696",
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
},
});
},
queryKey: ["prowlarrConnectionResult"],
});
console.log(data);
const submitHandler = () => {};
const initialData = {};
return (
<>
Prowlarr Settings.
<Form
onSubmit={submitHandler}
initialValues={initialData}
render={({ handleSubmit }) => (
<form>
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p>Configure Prowlarr integration here.</p>
<p>
Note that you need a Prowlarr instance hosted and running to
configure the integration.
</p>
<p>
See{" "}
<a
className="underline"
href="http://airdcpp.net/docs/installation/installation.html"
>
here
</a>{" "}
for Prowlarr installation instructions for various platforms.
</p>
</div>
</article>
</form>
)}
/>
</>
);
};
export default ProwlarrSettingsForm;

View File

@@ -0,0 +1,82 @@
import React, { ReactElement } from "react";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
import axios from "axios";
export const QbittorrentConnectionForm = (): ReactElement => {
const queryClient = new QueryClient();
// fetch settings
const { data, isLoading, isError } = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
const hostDetails = data?.data?.bittorrent?.client?.host;
// connect to qbittorrent client
// get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET",
}),
});
// Update action using a mutation
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "bittorrent" },
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["settings", "qbittorrentClientInfo"],
});
},
});
if (isError)
return (
<>
<pre>Something went wrong connecting to qBittorrent.</pre>
</>
);
if (!isLoading) {
return (
<>
<ConnectionForm
initialData={hostDetails}
formHeading={"qBittorrent Configuration"}
submitHandler={mutate}
/>
<span className="flex items-center mt-10 mb-4">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
qBittorrent Client Information
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--plug-circle-bold] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">Connected</p>
</span>
<pre className="font-hasklig text-sm text-slate-700 dark:text-slate-700">
{JSON.stringify(qbittorrentClientInfo?.data, null, 4)}
</pre>
</div>
</>
);
}
};
export default QbittorrentConnectionForm;

View File

@@ -0,0 +1,154 @@
import React, { useState, ReactElement } from "react";
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
import settingsObject from "../../constants/settings/settingsMenu.json";
import { isUndefined, map } from "lodash";
interface ISettingsProps {}
export const Settings = (props: ISettingsProps): ReactElement => {
const [active, setActive] = useState("gen-db");
console.log(active);
const settingsContent = [
{
id: "adc-hubs",
content: (
<div key="adc-hubs">
<AirDCPPHubsForm />
</div>
),
},
{
id: "adc-connection",
content: (
<div key="adc-connection">
<AirDCPPSettingsForm />
</div>
),
},
{
id: "qbt-connection",
content: (
<div key="qbt-connection">
<QbittorrentConnectionForm />
</div>
),
},
{
id: "prwlr-connection",
content: (
<>
<ProwlarrSettingsForm />
</>
),
},
{
id: "core-service",
content: <>a</>,
},
{
id: "flushdb",
content: (
<div key="flushdb">
<SystemSettingsForm />
</div>
),
},
];
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Settings
</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="flex flex-row">
<div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => {
return (
<div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul className="pl-4 mt-2">
{map(item.children, (item, idx) => (
<li key={item.id} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
</div>
{/* content for settings */}
<div className="flex mx-12">
<div className="">
{map(settingsContent, ({ id, content }) =>
active === id ? content : null,
)}
</div>
</div>
</div>
</section>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,66 @@
import React, { ReactElement } from "react";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
export const SystemSettingsForm = (): ReactElement => {
const { mutate: flushDb, isLoading } = useMutation({
mutationFn: async () => {
await axios({
url: `http://localhost:3000/api/library/flushDb`,
method: "POST",
});
},
});
return (
<div className="is-clearfix">
<div className="mt-4">
<h3 className="title">Flush DB and Temporary Folders</h3>
<h6 className="subtitle has-text-grey-light">
If you are encountering issues, start over using this functionality.
</h6>
<article className="message is-danger">
<div className="message-body is-size-6 is-family-secondary">
Flushing and resetting will clear out:
<p>
<small>(This action is irreversible)</small>
</p>
<ol>
<li>The mongo collection that holds library metadata</li>
<li>
Your <code>USERDATA_DIRECTORY</code> which includes
<code>covers</code>, <code>temporary</code> and
<code>expanded</code> subfolders.
</li>
<li>
Your <code>Elasticsearch indices</code>
</li>
</ol>
</div>
</article>
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
Your comic book files are not touched, and your settings will remain
intact.
</div>
</article>
<button
className={
isLoading ? "button is-danger is-loading" : "button is-danger"
}
onClick={() => flushDb()}
>
<span className="icon">
<i className="fas fa-eraser"></i>
</span>
<span>Flush DB & Temporary Folders</span>
</button>
</div>
</div>
);
};
export default SystemSettingsForm;

View File

@@ -0,0 +1,73 @@
import { isArray, map } from "lodash";
import React, { useEffect, ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions";
import { Card } from "../shared/Carda";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
import prettyBytes from "pretty-bytes";
const PotentialLibraryMatches = (props): ReactElement => {
const dispatch = useDispatch();
const comicBooks = useSelector(
(state: RootState) => state.comicInfo.comicBooksDetails,
);
useEffect(() => {
dispatch(getComicBooksDetailsByIds(props.matches));
}, []);
return (
<div className="potential-matches-container mt-10">
{isArray(comicBooks) ? (
map(comicBooks, (match) => {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${match.rawFileDetails.cover.filePath}`,
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div className="potential-issue-match mb-3">
<div className="columns">
<div className="column is-one-fifth">
<Card
imageUrl={filePath}
orientation={"vertical"}
hasDetails={false}
/>
</div>
<div className="search-result-details column">
<div className="is-size-5">{match.rawFileDetails.name}</div>
<pre className="code is-size-7">
{match.rawFileDetails.containedIn}
</pre>
<div className="field is-grouped is-grouped-multiline mt-4">
<div className="control">
<div className="tags has-addons">
<span className="tag">File Type</span>
<span className="tag is-primary">
{match.rawFileDetails.extension}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">File Size</span>
<span className="tag is-warning">
{prettyBytes(match.rawFileDetails.fileSize)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
})
) : (
<div>No matches found in library.</div>
)}
</div>
);
};
export default PotentialLibraryMatches;

View File

@@ -0,0 +1,258 @@
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
import React, { useEffect, ReactElement, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router";
import {
getComicBookDetailById,
getIssuesForSeries,
analyzeLibrary,
} from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches";
import Masonry from "react-masonry-css";
import { Card } from "../shared/Carda";
import SlidingPane from "react-sliding-pane";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
const VolumeDetails = (props): ReactElement => {
const breakpointColumnsObj = {
default: 6,
1100: 4,
700: 3,
500: 2,
};
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [matches, setMatches] = useState([]);
const [active, setActive] = useState(1);
// sliding panel init
const contentForSlidingPanel = {
potentialMatchesInLibrary: {
content: () => {
const ids = map(matches, partialRight(pick, "_id"));
const matchIds = ids.map((id: any) => id._id);
return <PotentialLibraryMatches matches={matchIds} />;
},
},
};
// sliding panel handlers
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches) => {
setSlidingPanelContentId("potentialMatchesInLibrary");
setMatches(potentialMatches);
setVisible(true);
}, []);
const analyzeIssues = useCallback((issues) => {
dispatch(analyzeLibrary(issues));
}, []);
const comicBookDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const issuesForVolume = useSelector(
(state: RootState) => state.comicInfo.issuesForVolume,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getIssuesForSeries(comicObjectId));
dispatch(getComicBookDetailById(comicObjectId));
}, []);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const IssuesInVolume = () => (
<>
{!isUndefined(issuesForVolume) ? (
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
Analyze Library
</div>
) : null}
<Masonry
breakpointCols={breakpointColumnsObj}
className="issues-container"
columnClassName="issues-column"
>
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
? issuesForVolume.map((issue) => {
return (
<Card
key={issue.id}
imageUrl={issue.image.thumb_url}
orientation={"vertical"}
hasDetails
borderColorClass={
!isEmpty(issue.matches) ? "green-border" : ""
}
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
onClick={() =>
openPotentialLibraryMatchesPanel(issue.matches)
}
>
<span className="tag is-warning mr-1">
{issue.issue_number}
</span>
{!isEmpty(issue.matches) ? (
<>
<span className="icon has-text-success">
<i className="fa-regular fa-asterisk"></i>
</span>
</>
) : null}
</Card>
);
})
: "loading"}
</Masonry>
</>
);
// Tab content and header details
const tabGroup = [
{
id: 1,
name: "Issues in Volume",
icon: <i className="fa-solid fa-layer-group"></i>,
content: <IssuesInVolume key={1} />,
},
{
id: 2,
icon: <i className="fa-regular fa-mask"></i>,
name: "Characters",
content: <div key={2}>asdasd</div>,
},
{
id: 3,
icon: <i className="fa-solid fa-scroll"></i>,
name: "Arcs",
content: <div key={3}>asdasd</div>,
},
];
// Tabs
const MetadataTabGroup = () => {
return (
<>
<div className="tabs">
<ul>
{tabGroup.map(({ id, name, icon }) => (
<li
key={id}
className={id === active ? "is-active" : ""}
onClick={() => setActive(id)}
>
<a>
<span className="icon is-small">{icon}</span>
{name}
</a>
</li>
))}
</ul>
</div>
{tabGroup.map(({ id, content }) => {
return active === id ? content : null;
})}
</>
);
};
if (
!isUndefined(comicBookDetails.sourcedMetadata) &&
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
) {
return (
<div className="container volume-details">
<div className="section">
{/* Title */}
<h1 className="title">
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
</h1>
<div className="columns is-multiline">
{/* Volume cover */}
<div className="column is-narrow">
<Card
imageUrl={
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.image.small_url
}
cardContainerStyle={{ maxWidth: 275 }}
orientation={"vertical"}
hasDetails={false}
/>
</div>
<div className="column is-three-fifths">
<div className="field is-grouped mt-2">
{/* Comicvine Id */}
<div className="control">
<div className="tags has-addons">
<span className="tag">ComicVine Id</span>
<span className="tag is-info is-light">
{
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.id
}
</span>
</div>
</div>
{/* Publisher */}
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning is-light">Publisher</span>
<span className="tag is-volume-related">
{
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</div>
</div>
</div>
{/* Deck */}
<div>
{!isEmpty(
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.description,
)
? ellipsize(
convert(
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div>
</div>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
</div>
<MetadataTabGroup />
</div>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
);
} else {
return <></>;
}
};
export default VolumeDetails;

View File

@@ -0,0 +1,179 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import { searchIssue } from "../../actions/fileops.actions";
import Card from "../shared/Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const {
data: volumes,
isSuccess,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
pagination: {
size: 25,
from: 0,
},
type: "volumes",
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
console.log(volumes);
const columnData = useMemo(
(): any => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorKey: "_source",
cell: (row): any => {
const foo = row.getValue();
return (
<div className="flex flex-row gap-3 mt-5">
<Card
imageUrl={
foo.sourcedMetadata.comicvine.volumeInformation.image
.small_url
}
orientation={"cover-only"}
hasDetails={false}
/>
<div className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg h-fit">
<span className="text-xl mb-1">
{foo.sourcedMetadata.comicvine.volumeInformation.name}
</span>
<p>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine.volumeInformation
.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</p>
</div>
</div>
);
},
},
{
header: "Other Details",
columns: [
{
header: "Downloads",
accessorKey: "_source.acquisition.directconnect",
align: "right",
cell: (props) => {
const row = props.getValue();
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
{row.length > 0 ? (
<span className="tag is-warning">{row.length}</span>
) : null}
</div>
);
},
},
{
header: "Publisher",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
},
{
header: "Issue Count",
accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
const row = props.getValue();
return (
<div className="mt-5">
{/* issue count */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-6 h-6"></i>
</span>
<span className="text-lg text-slate-500 dark:text-slate-900">
{row}
</span>
</span>
</div>
);
},
},
],
},
],
[],
);
return (
<div>
<section className="">
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Volumes
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your collection of volumes.
</p>
</div>
</div>
</div>
</header>
{isSuccess ? (
<div>
<div className="library">
<T2Table
sourceData={volumes?.data.hits.hits}
totalPages={volumes?.data.hits.hits.length}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
}}
columns={columnData}
/>
</div>
</div>
) : null}
{isError ? (
<div>An error was encountered while retrieving volumes</div>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
);
};
export default Volumes;

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