Compare commits

...

548 Commits

Author SHA1 Message Date
540c711904 🖼️ Adding screenshots as of December 2022 2022-12-06 13:51:25 -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
130 changed files with 18756 additions and 21189 deletions

View File

@@ -5,9 +5,9 @@
"@babel/preset-typescript"
],
"plugins": [
"react-hot-loader/babel",
"@babel/transform-runtime",
"@babel/plugin-proposal-class-properties"
"@babel/plugin-proposal-class-properties",
"@babel/plugin-syntax-top-level-await"
],
"env": {
"production": {

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"],
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@master
with:
name: frishi/threetwo
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

10
.gitignore vendored
View File

@@ -5,9 +5,17 @@ docs/
userdata/
dist/
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.js Normal file
View File

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

9
.storybook/preview.js Normal file
View File

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

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

BIN
ComicVine Matching.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
DC++ integration.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

BIN
Dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:17.3-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 python3 git libc6-compat autoconf automake bash libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
COPY . .
EXPOSE 3050
ENTRYPOINT [ "npm", "start" ]

BIN
Library.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,53 @@
# 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)
### 🦄 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-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:3050` in your default browser
4. For testing `OPDS` functionality, create a folder called `comics` under `/src/server` and put some comics in there. The `OPDS` feed is accessed to `http://localhost:8050/api/opds`
5. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## 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)

0
contributing.md Normal file
View File

View File

@@ -1,7 +1,15 @@
{
"tags": { "allowUnknownTags": true },
"tags": {
"allowUnknownTags": true,
"dictionaries": [
"jsdoc",
"closure"
]
},
"source": {
"include": ["./src/"],
"include": [
"./src/client"
],
"includePattern": "\\.(jsx|js|ts|tsx)$"
},
"plugins": [
@@ -10,7 +18,11 @@
"plugins/markdown",
"node_modules/better-docs/typescript"
],
"templates": { "better-docs": { "name": "My React components" } },
"templates": {
"better-docs": {
"name": "ThreeTwo UI components"
}
},
"opts": {
"destination": "docs/",
"readme": "README.md",
@@ -19,4 +31,4 @@
"verbose": true,
"template": "node_modules/better-docs"
}
}
}

View File

@@ -11,14 +11,22 @@
"server": "tsc -p tsconfig.server.json && node server/",
"dev": "concurrently \"nodemon\" \"npm run client\"",
"server-dev": "nodemon",
"docs": "jsdoc -c jsdoc.json"
"docs": "jsdoc -c jsdoc.json",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.17",
"@bluelovers/fast-glob": "^3.0.4",
"@types/event-stream": "^3.3.34",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@dnd-kit/core": "^4.0.0",
"@dnd-kit/sortable": "^5.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.1.1",
"@parcel/config-default": "^2.6.0",
"@redux-devtools/extension": "^3.2.2",
"@tanstack/react-table": "^8.5.11",
"@types/mime-types": "^2.1.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
@@ -27,64 +35,101 @@
"@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",
"airdcpp-apisocket": "^2.4.4",
"array-sort-by": "^1.2.1",
"axios": "^0.27.2",
"axios-rate-limit": "^1.3.0",
"axios-simple-cache-adapter": "^1.1.0",
"babel-polyfill": "^6.26.0",
"better-docs": "^2.3.2",
"calibre-opds": "^1.0.7",
"babel-preset-minify": "^0.5.2",
"better-docs": "^2.7.2",
"comlink-loader": "^2.0.0",
"compromise": "^13.11.3",
"compromise-dates": "^2.2.1",
"compromise-numbers": "^1.4.0",
"compromise-sentences": "^0.3.0",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.1.0",
"event-stream": "^4.0.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",
"final-form-arrays": "^3.0.2",
"html-to-text": "^8.1.0",
"jsdoc": "^3.6.10",
"lodash": "^4.17.21",
"opds-extra": "^3.0.9",
"pretty-bytes": "^5.6.0",
"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",
"prop-types": "^15.8.1",
"qs": "^6.10.5",
"react": "^18.1.0",
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.3.5",
"react-day-picker": "^8.0.6",
"react-dom": "^18.1.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.3",
"react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"react-redux": "^7.2.6",
"react-router": "^6.2.2",
"react-router-dom": "^6.2.2",
"react-select": "^5.3.2",
"react-select-async-paginate": "^0.6.1",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.0.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.0.5",
"redux-first-history": "^5.0.9",
"redux-socket.io-middleware": "^1.0.4",
"redux-thunk": "^2.4.1",
"sharp": "^0.30.5",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^5.3.5",
"threetwo-ui-typings": "^1.0.14",
"voca": "^1.4.0",
"websocket": "^1.0.34",
"ws": "^7.5.3",
"ws-calibre": "bluelovers/ws-calibre",
"xml2js": "^0.4.23",
"xregexp": "^5.0.2"
},
"devDependencies": {
"@babel/cli": "^7.13.10",
"@babel/core": "^7.13.10",
"@babel/plugin-syntax-top-level-await": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.13.10",
"@babel/preset-react": "^7.12.13",
"@babel/preset-typescript": "^7.13.0",
"@fortawesome/fontawesome-free": "^5.15.3",
"@root/walk": "^1.1.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
"@storybook/addon-actions": "^6.5.9",
"@storybook/addon-essentials": "^6.5.9",
"@storybook/addon-interactions": "^6.5.9",
"@storybook/addon-links": "^6.5.9",
"@storybook/builder-webpack4": "^6.5.9",
"@storybook/builder-webpack5": "^6.5.9",
"@storybook/manager-webpack4": "^6.5.9",
"@storybook/manager-webpack5": "^6.5.9",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^6.5.9",
"@storybook/testing-library": "^0.0.13",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@tsconfig/node14": "^1.0.0",
"@types/express": "^4.17.8",
"@types/jest": "^26.0.20",
"@types/lodash": "^4.14.168",
"@types/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",
@@ -93,46 +138,33 @@
"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",
"compromise-strict": "^0.0.2",
"concurrently": "^4.0.0",
"connected-react-router": "^6.9.1",
"copy-webpack-plugin": "^9.0.1",
"css-loader": "^5.1.2",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"etl": "^0.6.12",
"express": "^4.17.1",
"file-loader": "^6.2.0",
"fs-extra": "^9.1.0",
"html-webpack-plugin": "^5.3.1",
"image-webpack-loader": "^7.0.1",
"http-response-stream": "^1.0.9",
"image-webpack-loader": "^8.1.0",
"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",
"node-sass": "^7.0.1",
"nodemon": "^1.17.3",
"npm": "^7.9.0",
"pino": "^6.11.2",
"pino-pretty": "^4.7.1",
"npm": "^8.11.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",
"react-refresh": "^0.14.0",
"rimraf": "^3.0.2",
"sass-loader": "^11.0.1",
"source-map-loader": "^0.2.4",
@@ -140,11 +172,14 @@
"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": "^5.73.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^3.11.2",
"webpack-dev-server": "^4.7.4",
"webpack-merge": "^5.7.3"
},
"resolutions": {
"@storybook/react/webpack": "^5"
}
}

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,242 @@
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,
} 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;
}
function sleep(ms: number): Promise<NodeJS.Timeout> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const search =
(data: SearchData, ADCPPSocket: any, credentials: any) =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect(
credentials.username,
credentials.password,
true,
);
}
const instance: SearchInstance = await ADCPPSocket.post("search");
dispatch({
type: AIRDCPP_SEARCH_IN_PROGRESS,
});
// We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
dispatch({
type: AIRDCPP_SEARCH_RESULTS_ADDED,
groupedResult,
});
},
instance.id,
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
dispatch({
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
groupedResult,
});
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
dispatch({
type: AIRDCPP_HUB_SEARCHES_SENT,
searchInfo,
instance,
});
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
};
export const downloadAirDCPPItem =
(searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String, size: Number, type: any,
ADCPPSocket: any,
credentials: any,
): void =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect(
`${credentials.username}`,
`${credentials.password}`,
true,
);
}
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(
`${credentials.username}`,
`${credentials.password}`,
true,
);
}
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(
`${credentials.username}`,
`${credentials.password}`,
true,
);
}
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,26 +1,67 @@
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import {
AxiosCacheRequestConfig,
createCacheAdapter,
} from "axios-simple-cache-adapter";
import qs from "qs";
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 axiosCacheAdapter = createCacheAdapter();
const http = rateLimiter(axios.create(), {
maxRequests: 1,
perMilliseconds: 1000,
maxRPS: 1,
});
export const getWeeklyPullList = (options) => async (dispatch) => {
try {
dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
});
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: options,
axiosCacheAdapter,
cache: 1000, // value in MS
} as AxiosCacheRequestConfig).then((response) => {
dispatch({
type: CV_WEEKLY_PULLLIST_FETCHED,
data: response.data.result,
});
});
} catch (error) {
console.log(error);
}
};
export const comicinfoAPICall = (options) => async (dispatch) => {
try {
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,
@@ -38,7 +79,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
case "search":
dispatch({
type: CV_SEARCH_SUCCESS,
result: response.data,
searchResults: response.data,
});
break;
@@ -53,3 +94,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,48 @@
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,
LIBRARY_SERVICE_HOST,
SEARCH_SERVICE_BASE_URI,
} from "../constants/endpoints";
import {
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED,
CV_API_CALL_IN_PROGRESS,
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_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS,
LS_TOGGLE_IMPORT_QUEUE,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
WANTED_COMICS_FETCHED,
VOLUMES_FETCHED,
CV_WEEKLY_PULLLIST_FETCHED,
} 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 { removeLeadingPeriod } from "../shared/utils/formatting.utils";
import { isNil, map } from "lodash";
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,17 +57,10 @@ 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) => {
export const fetchComicBookMetadata = () => async (dispatch) => {
const extractionOptions = {
sourceFolder: options,
extractTarget: "cover",
targetExtractionFolder: "./userdata/covers",
extractionMode: "bulk",
@@ -50,112 +69,291 @@ export const fetchComicBookMetadata = (options) => async (dispatch) => {
page: 1,
},
};
const walkedFolders = await walkFolder("./comics");
const socket = io(SOCKET_BASE_URI, {
reconnectionDelayMax: 10000,
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,
// }),
// );
dispatch({
type: LS_IMPORT,
meta: { remote: true },
data: { extractionOptions },
});
};
export const toggleImportQueueStatus = (options) => async (dispatch) => {
dispatch({
type: LS_TOGGLE_IMPORT_QUEUE,
meta: { remote: true },
data: { manjhul: "jigyadam", action: options.action },
});
};
/**
* 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;
socket.on("disconnect", () => {
console.log(`disconnect`);
});
socket.emit("importComicsInDB", {
action: "getComicCovers",
params: {
extractionOptions,
walkedFolders,
const response = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions,
predicate,
},
});
socket.on("comicBookCoverMetadata", (data: IExtractedComicBookCoverFile) => {
dispatch({
type: IMS_COMICBOOK_METADATA_FETCHED,
data,
dataTransferred: true,
});
});
};
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) => {
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) => {
/**
* 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, payload?: any) => (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",
const comicBookMetadata = {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
transformResponse: [
(r) => {
const searchMatches = JSON.parse(r);
return matchScorer(searchMatches.results, {
issue: issueSearchQuery,
series: seriesSearchQuery,
});
},
],
},
sourcedMetadata: payload || null,
acquisition: { source: { wanted: true, name: sourceName } },
};
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: CV_SEARCH_SUCCESS,
searchResults: response.data,
searchQueryObject: {
issue: issueSearchQuery,
series: seriesSearchQuery,
},
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);
}
};
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) => async (dispatch) => {
const comicBookPages: string[] = [];
dispatch({
type: CV_CLEANUP,
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
});
const extractedComicBookArchive = await axios({
method: "POST",
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: path,
},
});
map(extractedComicBookArchive.data, (page) => {
console.log(page);
const pageFilePath = removeLeadingPeriod(page);
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
comicBookPages.push(imagePath);
});
dispatch({
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
extractedComicBookArchive: comicBookPages,
});
};
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.body,
});
break;
case "globalSearchBar":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
data: response.data.body,
});
break;
case "libraryPage":
dispatch({
type: SS_SEARCH_RESULTS_FETCHED,
data: response.data.body,
});
break;
case "volumesPage":
dispatch({
type: VOLUMES_FETCHED,
data: response.data.body,
});
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,69 @@
import axios from "axios";
import {
SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
} from "../constants/action-types";
import {
LIBRARY_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
} from "../constants/endpoints";
export const saveSettings =
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
method: "POST",
data: { settingsPayload, settingsObjectId },
});
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: result.data,
});
};
export const getSettings = (settingsKey?) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
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,
});
}
};

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

View File

@@ -0,0 +1,96 @@
import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form";
import { useDispatch } from "react-redux";
import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select";
import { saveSettings } from "../../actions/settings.actions";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
const dispatch = useDispatch();
const [hubList, setHubList] = useState([]);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { settings, socket },
} = airDCPPConfiguration;
useEffect(() => {
(async () => {
if (!isEmpty(settings)) {
const hubs = await socket.get(`hubs`);
const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
setHubList(hubSelectionOptions);
}
})();
}, []);
const onSubmit = (values) => {
if (!isUndefined(values.hubs)) {
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
}
};
const validate = async () => {};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
return (
<>
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<div>
<h3 className="title">Hubs</h3>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against.
</h6>
</div>
<div className="field">
<label className="label">AirDC++ Host</label>
<div className="control">
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
</div>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
)}
/>
<div className="mt-4">
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article>
</div>
<div className="box mt-3">
<h6>Selected hubs</h6>
{settings.directConnect.client.hubs.map(({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
))}
</div>
</>
);
};
export default AirDCPPHubsForm;

View File

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

View File

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

View File

@@ -1,36 +1,117 @@
import * as React from "react";
import { hot } from "react-hot-loader";
import Dashboard from "./Dashboard";
import React, { ReactElement, useContext, useEffect } from "react";
import Dashboard from "./Dashboard/Dashboard";
import Import from "./Import";
import { ComicDetail } from "./ComicDetail";
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
import LibraryGrid from "./Library/LibraryGrid";
import Search from "./Search";
import Settings from "./Settings";
import VolumeDetail from "./VolumeDetail/VolumeDetail";
import Downloads from "./Downloads/Downloads";
import { Switch, Route } from "react-router";
import { Routes, Route } from "react-router-dom";
import Navbar from "./Navbar";
import "../assets/scss/App.scss";
import {
AirDCPPSocketContextProvider,
AirDCPPSocketContext,
} from "../context/AirDCPPSocket";
import { isEmpty, isUndefined } from "lodash";
import { AIRDCPP_DOWNLOAD_PROGRESS_TICK } from "../constants/action-types";
import { useDispatch } from "react-redux";
class App extends React.Component {
public render() {
return (
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
const AirDCPPSocketComponent = (): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
useEffect(() => {
const initializeAirDCPPEventListeners = async () => {
if (
!isUndefined(airDCPPConfiguration.airDCPPState) &&
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
) {
await airDCPPConfiguration.airDCPPState.socket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
}
);
// download tick listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
dispatch({
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
downloadProgressData,
});
},
);
// download complete listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
console.log("IM THE MAN UP IN THIS")
}
},
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle changes",
);
console.log(
"[AirDCPP]: Listener registered - listening to transfer completion",
);
}
};
initializeAirDCPPEventListeners();
}, [airDCPPConfiguration]);
return <></>;
};
export const App = (): ReactElement => {
return (
<AirDCPPSocketContextProvider>
<div>
<AirDCPPSocketComponent />
<Navbar />
<Switch>
<Route exact path="/">
<Dashboard />
</Route>
<Route path="/import">
<Import path={"./comics"} />
</Route>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/import" element={<Import path={"./comics"} />} />
<Route path="/library" element={<TabulatedContentContainer category="library" />} />
<Route path="/library-grid" element={<LibraryGrid />} />
<Route path="/downloads" element={<Downloads data={{}} />} />
<Route path="/search" element={<Search />} />
<Route
path={"/comic/details/:comicObjectId"}
component={ComicDetail}
element={<ComicDetailContainer />}
/>
</Switch>
<Route
path={"/volume/details/:comicObjectId"}
element={<VolumeDetail />}
/>
<Route path="/settings" element={<Settings />} />
<Route path="/pull-list/all" element={<TabulatedContentContainer category="pullList" />} />
<Route path="/wanted/all" element={<TabulatedContentContainer category="wanted" />} />
<Route path="/volumes/all" element={<TabulatedContentContainer category="volumes" />} />
</Routes>
</div>
);
}
}
</AirDCPPSocketContextProvider>
);
};
declare let module: Record<string, unknown>;
export default hot(module)(App);
export default App;

View File

@@ -4,13 +4,17 @@ import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../shared/utils/formatting.utils";
import { isUndefined, isEmpty } from "lodash";
import { isUndefined, isEmpty, isNil } from "lodash";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
import ellipsize from "ellipsize";
interface IProps {
comicBookCoversMetadata: IExtractedComicBookCoverFile;
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
mongoObjId?: number;
hasTitle: boolean;
title?: string;
isHorizontal: boolean;
}
interface IState {}
@@ -22,34 +26,30 @@ class Card extends React.Component<IProps, IState> {
public drawCoverCard = (
metadata: IExtractedComicBookCoverFile,
): JSX.Element => {
const filePath = encodeURI(
"http://localhost:3000" +
removeLeadingPeriod(metadata.path) +
"/" +
metadata.name,
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div>
<div className="card generic-card">
<div>
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
<div className="card-image">
<figure className="image">
<img
src={escapePoundSymbol(filePath)}
alt="Placeholder image"
/>
<img src={filePath} alt="Placeholder image" />
</figure>
</div>
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
{this.props.hasTitle && (
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
)}
</div>
</div>
</div>

View File

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

View File

@@ -1,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,335 @@
import React, {
useCallback,
useContext,
ReactElement,
useEffect,
useState,
} from "react";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form";
import { isEmpty, isNil, map } from "lodash";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
interface IAcquisitionPanelProps {
query: any;
comicObjectId: any;
comicObject: any;
settings: any;
}
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Selectors for picking state
const airDCPPSearchResults = useSelector((state: RootState) => {
return state.airdcpp.searchResults;
});
const isAirDCPPSearchInProgress = useSelector(
(state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
);
const searchInfo = useSelector(
(state: RootState) => state.airdcpp.searchInfo,
);
const searchInstance: SearchInstance = useSelector(
(state: RootState) => state.airdcpp.searchInstance,
);
// const settings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
const [dcppQuery, setDcppQuery] = useState({});
useEffect(() => {
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}
}, [airDCPPConfiguration]);
const getDCPPSearchResults = useCallback(
async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
priority: 5,
};
dispatch(
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
}),
);
},
[dispatch, airDCPPConfiguration],
);
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
dispatch(
downloadAirDCPPItem(
searchInstanceId, resultId,
props.comicObjectId,
name, size, type,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
// this is to update the download count badge on the downloads tab
dispatch(
getBundlesForComic(
props.comicObjectId,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
},
[airDCPPConfiguration],
);
return (
<>
<div className="comic-detail columns">
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form
onSubmit={handleSubmit}
className="column is-three-quarters"
>
<div className="box search">
<div className="columns">
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info">
Use this to perform a manual search.
</span>
</div>
);
}}
</Field>
<div className="column">
<button
type="submit"
className={
isAirDCPPSearchInProgress
? "button is-loading is-warning"
: "button"
}
>
<span className="icon is-small">
<img src="/img/airdcpp_logo.svg" />
</span>
<span className="airdcpp-text">Search on AirDC++</span>
</button>
</div>
</div>
</div>
</form>
)}
/>
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
)}
</div>
{/* AirDC++ search instance details */}
{!isNil(searchInfo) && !isNil(searchInstance) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
({ value }) => (
<span className="tag is-warning" key={value}>
{value}
</span>
),
)}
</div>
</dt>
<dt>
Query:{" "}
<span className="has-text-weight-semibold">
{searchInfo.query.pattern}
</span>
</dt>
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<dd>File type: {searchInfo.query.file_type}</dd>
</dl>
</div>
</div>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="column">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
<tr
key={idx}
className={
!isNil(result.dupe) ? "dupe-search-result" : ""
}
>
<td>
<p className="mb-2">
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}{" "}
{ellipsize(result.name, 70)}
</p>
<dl>
<dd>
<div className="tags">
{!isNil(result.dupe) ? (
<span className="tag is-warning">Dupe</span>
) : null}
<span className="tag is-light is-info">
{result.users.user.nicks}
</span>
{result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}>
{flag}
</span>
))}
</div>
</dd>
</dl>
</td>
<td>
<span className="tag is-light is-info">
{result.type.id === "directory"
? "directory"
: result.type.str}
</span>
</td>
<td>
<div className="tags has-addons">
<span className="tag is-success">
{result.slots.free} free
</span>
<span className="tag is-light">
{result.slots.total}
</span>
</div>
</td>
<td>
<a
onClick={() =>
downloadDCPPResult(
searchInstance.id,
result.id,
result.name,
result.size,
result.type,
)
}
>
<i className="fas fa-file-download"></i>
</a>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from <code>ADCS</code> hubs are
more reliable than <code>NMDCS</code> ones.
</div>
</article>
</div>
)}
</div>
</>
);
};
export default AcquisitionPanel;

View File

@@ -0,0 +1,93 @@
import { filter, isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => {
const { data } = props;
const { setSlidingPanelContentId, setVisible } = props.handlers;
const dispatch = useDispatch();
const openDrawerWithCVMatches = useCallback(() => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
if (!isUndefined(data.rawFileDetails)) {
issueSearchQuery = refineQuery(data.rawFileDetails.name);
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [dispatch, data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
return (
<Select
className="basic-single"
classNamePrefix="select"
components={{ Placeholder }}
placeholder={
<span>
<i className="fa-solid fa-list"></i> Actions
</span>
}
name="actions"
isSearchable={false}
options={filteredActionOptions}
onChange={handleActionSelection}
/>
);
};
export default Menu;

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,309 @@
import React, { useState, ReactElement, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import Card from "../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 DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash";
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";
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 },
},
userSettings,
} = data;
const [page, setPage] = useState(1);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const comicVineSearchQueryObject = useSelector(
(state: RootState) => state.comicInfo.searchQuery,
);
const comicVineAPICallProgress = useSelector(
(state: RootState) => state.comicInfo.inProgress,
);
const extractedComicBook = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive,
);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const dispatch = useDispatch();
const openModal = useCallback((filePath) => {
setIsOpen(true);
dispatch(extractComicArchive(filePath));
}, []);
const afterOpenModal = useCallback((things) => {
// references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
console.log(things);
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// sliding panel init
const contentForSlidingPanel = {
CVMatches: {
content: (props) => (
<>
<div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails}/>
</div>
</div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
{inferredMetadata.issue ? (
<div className="ml-3">
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
<span className="tag"> # {inferredMetadata.issue.number} </span>
</div>
) : null}
{!comicVineAPICallProgress ? (
<ComicVineMatchPanel
props={{
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
comicObjectId,
}}
/>) : (<div className="progress-indicator-container" >
<div className="indicator">
<Loader
type="MutatingDots"
color="#CCC"
secondaryColor="#999"
height={100}
width={100}
visible={comicVineAPICallProgress}
/>
</div>
</div >)}
</>),
},
editComicBookMetadata: {
content: () => <EditMetadataPanel />,
},
};
// 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.cover);
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="fa-solid fa-layer-group"></i>,
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>,
content: (
<div className="columns" key={2}>
<div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
name: "Archive Operations",
content: <ArchiveOperations data={data.data} key={3} />,
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: <i className="fa-solid fa-floppy-disk"></i>,
name: "Acquisition",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
) : (
"Downloads"
),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
),
shouldShow: true,
},
];
// filtered Tabs
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
// Determine which cover image to use:
// 1. from the locally imported or
// 2. from the CV-scraped version
return (
<section className="container">
<div className="section">
{!isNil(data) && !isEmpty(data) && (
<>
<h1 className="title">{issueName}</h1>
<div className="columns is-multiline">
<div className="column is-narrow">
<Card
imageUrl={url}
orientation={"vertical"}
hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }}
/>
{/* action dropdown */}
<div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div>
</div>
{/* raw file details */}
<div className="column is-three-fifths">
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && (
<>
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
}}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
<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"
/>
)}
</Modal>
</>
)}
</div>
</div>
{<TabControls filteredTabs={filteredTabs} />}
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</>
)}
</div>
</section>
);
};
export default ComicDetail;

View File

@@ -0,0 +1,22 @@
import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail";
export const ComicDetailContainer = (): ReactElement | null => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const dispatch = useDispatch();
const { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
) : null;
};

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 { isUndefined } from "lodash";
import Card from "../Carda";
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (
<div className="column is-two-thirds">
<div className="comic-detail comicvine-metadata">
<dl>
<dt>ComicVine Metadata</dt>
<dd className="is-size-7">
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</dd>
<dd>
<div className="columns mt-2">
<div className="column is-2">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column is-10">
<dl>
<dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
</dt>
<dd>
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</dd>
<dd>
Published by
<span className="has-text-weight-semibold">
{" "}
{data.volumeInformation.publisher.name}
</span>
</dd>
<dd>
Total issues in this volume:
{data.volumeInformation.count_of_issues}
</dd>
<dd>
<div className="field is-grouped mt-2">
{data.issue_number && (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Issue Number</span>
<span className="tag is-warning">
{data.issue_number}
</span>
</div>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Detected Type</span>
<span className="tag is-warning">
{
detectIssueTypes(
data.volumeInformation.description,
).displayName
}
</span>
</div>
</div>
) : data.resource_type ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Type</span>
<span className="tag is-warning">
{data.resource_type}
</span>
</div>
</div>
) : null}
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">
ComicVine Issue ID
</span>
<span className="tag is-success">{data.id}</span>
</div>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
</div>
);
};
export default ComicVineDetails;
ComicVineDetails.propTypes = {
updatedAt: PropTypes.string,
data: PropTypes.shape({
name: PropTypes.string,
number: PropTypes.string,
resource_type: PropTypes.string,
id: PropTypes.number,
}),
};

View File

@@ -0,0 +1,27 @@
import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "../MatchResult";
import { isEmpty } from "lodash";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const {
comicObjectId,
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
} = comicVineData.props;
return (
<>
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<MatchResult
matchData={comicVineSearchResults}
comicObjectId={comicObjectId}
/>
)}
</div>
</>
);
};
export default ComicVineMatchPanel;

View File

@@ -0,0 +1,35 @@
import prettyBytes from "pretty-bytes";
import React, { ReactElement } from "react";
export const DownloadProgressTick = (props): ReactElement => {
return (
<div >
<h4 className="is-size-6">{props.data.name}</h4>
<div>
<span className="is-size-3 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "}
</span>
<progress
className="progress is-small is-success"
value={props.data.downloaded_bytes}
max={props.data.size}
>
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
100}
%
</progress>
</div>
<div className="is-size-5">
{prettyBytes(props.data.speed)} per second.
</div>
<div className="is-size-5">
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,108 @@
import React, { useEffect, useContext, ReactElement } from "react";
import {
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
interface IDownloadsPanelProps {
data: any;
comicObjectId: string;
}
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
const bundles = useSelector((state: RootState) => {
return state.airdcpp.bundles;
});
// AirDCPP Socket initialization
const userSettings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => {
try {
if (!isEmpty(userSettings)) {
dispatch(
getBundlesForComic(props.comicObjectId, socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
}
} catch (error) {
throw new Error(error);
}
}, [dispatch, airDCPPConfiguration]);
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
return !isNil(props.data) ? (
<>
<div className="columns is-multiline">
{!isEmpty(socket) ? (
<Bundles data={bundles} />
) : (
<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>
)}
</div>
</>
) : null;
};
export default DownloadsPanel;

View File

@@ -0,0 +1,344 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
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 AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const rawFileDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
);
const dispatch = useDispatch();
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">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="issue_name"
component="input"
className="input"
initialValue={rawFileDetails}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
</div>
</div>
{/* Issue Number and year */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="issue_number"
component="input"
className="input"
placeholder="Issue Number"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
</div>
{/* year */}
<div className="field">
<p className="control">
<Field
name="issue_year"
component="input"
className="input"
/>
</p>
</div>
</div>
</div>
{/* page count */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="page_count"
component="input"
className="input"
placeholder="Page Count"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-note-sticky"></i>
</span>
</p>
</div>
</div>
</div>
{/* Description */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Description</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"description"}
className="textarea"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
</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,96 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes";
import { isUndefined } from "lodash";
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata } = props.data;
return (
<>
<div className="comic-detail raw-file-details">
<dl>
<dt>Raw File Details</dt>
<dd className="is-size-7">
{rawFileDetails.containedIn +
"/" +
rawFileDetails.name +
rawFileDetails.extension}
</dd>
<dd>
<div className="field is-grouped mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Size</span>
<span className="tag is-info is-light">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Extension</span>
<span className="tag is-primary is-light">
{rawFileDetails.extension}
</span>
</div>
</div>
</div>
</dd>
</dl>
</div>
<div className="content comic-detail raw-file-details mt-3">
<dl>
{/* inferred metadata */}
<dt>Inferred Issue Metadata</dt>
<dd>
<div className="field is-grouped mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Name</span>
<span className="tag is-info is-light">
{inferredMetadata.issue.name}
</span>
</div>
</div>
{!isUndefined(inferredMetadata.issue.number) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary is-light">
{inferredMetadata.issue.number}
</span>
</div>
</div>
) : null}
</div>
</dd>
</dl>
</div>
</>
);
};
export default RawFileDetails;
RawFileDetails.propTypes = {
data: PropTypes.shape({
rawFileDetails: PropTypes.shape({
containedIn: PropTypes.string,
name: PropTypes.string,
fileSize: PropTypes.number,
path: PropTypes.string,
extension: PropTypes.string,
cover: PropTypes.shape({
filePath: PropTypes.string,
}),
}),
inferredMetadata: PropTypes.shape({
issue: PropTypes.shape({
year: PropTypes.string,
name: PropTypes.string,
number: PropTypes.number,
subtitle: PropTypes.string,
}),
}),
}),
};

View File

@@ -0,0 +1,52 @@
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { useSelector } from "react-redux";
export const TabControls = (props): ReactElement => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const { filteredTabs } = props;
const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
setActive(filteredTabs[0].id);
}, [comicBookDetailData]);
return (
<>
<div className="tabs">
<ul>
{filteredTabs.map(({ id, name, icon }) => (
<li
key={id}
className={id === active ? "is-active" : ""}
onClick={() => setActive(id)}
>
{/* Downloads tab and count badge */}
<a>
{id === 5 &&
!isNil(comicBookDetailData.acquisition.directconnect) ? (
<span className="download-icon-labels">
<i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light">
{comicBookDetailData.acquisition.directconnect.downloads.length}
</span>
</span>
) : (
<span className="icon is-small">{icon}</span>
)}
{name}
</a>
</li>
))}
</ul>
</div>
{filteredTabs.map(({ id, content }) => {
return active === id ? content : null;
})}
</>
);
};
export default TabControls;

View File

@@ -0,0 +1,121 @@
import React, { ReactElement, useCallback, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { DnD } from "../../DnD";
import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas";
export const ArchiveOperations = (props): ReactElement => {
const { data } = props;
const isComicBookExtractionInProgress = useSelector(
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
);
const extractedComicBookArchive = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive,
);
const imageAnalysisResult = useSelector((state: RootState) => {
return state.fileOps.imageAnalysisResults;
});
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => {
dispatch(extractComicArchive(data.rawFileDetails.filePath));
}, []);
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
const [currentImage, setCurrentImage] = useState([]);
// sliding panel init
const contentForSlidingPanel = {
imageAnalysis: {
content: () => {
return (
<div>
<pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre>
</div>
);
},
},
};
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<button
className={
isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
>
<span className="icon is-small">
<i className="fa-solid fa-box-open"></i>
</span>
<span>Unpack comic archive</span>
</button>
<div className="columns">
<div className="mt-5">
{!isEmpty(extractedComicBookArchive) ? (
<DnD
data={extractedComicBookArchive}
onClickHandler={openImageAnalysisPanel}
/>
) : null}
</div>
{!isEmpty(extractedComicBookArchive) ? (
<div className="column mt-5">
<Sticky enabled={true} top={70} bottomBoundary={3000}>
<div className="card">
<div className="card-content">
<span className="has-text-size-4">
{extractedComicBookArchive.length} pages
</span>
<button className="button is-small is-light is-primary is-outlined">
<span className="icon is-small">
<i className="fa-solid fa-compress"></i>
</span>
<span>Convert to CBZ</span>
</button>
</div>
</div>
</Sticky>
</div>
) : null}
</div>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Image Analysis"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
);
};
export default ArchiveOperations;

View File

@@ -0,0 +1,57 @@
import { isUndefined } from "lodash";
import React, { ReactElement } from "react";
export const ComicInfoXML = (data): ReactElement => {
const { json } = data;
return (
<div className="comicInfo-metadata">
<dl className="has-text-size-7">
<dd className="has-text-weight-medium">{json.series[0]}</dd>
<dd className="mt-2 mb-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.publisher[0]}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Issue #</span>
<span className="tag is-warning is-light">
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.pagecount[0]}
</span>
</span>
</div>
{!isUndefined(json.genre) && (
<div className="control">
<span className="tags has-addons">
<span className="tag">Genre</span>
<span className="tag is-success is-light">
{json.genre[0]}
</span>
</span>
</div>
)}
</div>
</dd>
<dd>
<span className="is-size-7">{json.notes[0]}</span>
</dd>
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
</dl>
</div>
);
};
export default ComicInfoXML;

View File

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

View File

@@ -1,22 +1,31 @@
import React from "react";
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../actions/fileops.actions";
import { useDispatch } from "react-redux";
/**
* Component for accepting ComicVine search parameters
* Component for performing search against ComicVine
*
* @component
* @example
* const age = 21
* const name = 'Jitendra Nirnejak'
* return (
* <User age={age} name={name} />
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = () => {
const onSubmit = () => {
return true;
};
export const ComicVineSearchForm = (data) => {
const dispatch = useDispatch();
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
number: value.issueNumber,
subtitle: "",
year: value.issueYear,
},
};
dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
};
@@ -28,7 +37,7 @@ export const ComicVineSearchForm = () => {
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="field is-normal">
<label className="label">Issue Details</label>
<label className="label mb-2 is-size-5">Search Manually</label>
</span>
<div className="field is-horizontal">
<div className="field-body">
@@ -48,11 +57,15 @@ export const ComicVineSearchForm = () => {
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueNumber">
{(props) => (
<p className="control is-expanded has-icons-left">
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
@@ -65,18 +78,36 @@ export const ComicVineSearchForm = () => {
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-info is-light is-outlined is-small"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-hand-sparkles"></i>
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
@@ -89,16 +120,7 @@ export const ComicVineSearchForm = () => {
/>
);
return (
<Collapsible
trigger={"Match Manually"}
triggerTagName="a"
triggerClassName={"is-size-6"}
triggerOpenedClassName={"is-size-6"}
>
<MyForm />
</Collapsible>
);
return <MyForm />;
};
export default ComicVineSearchForm;

View File

@@ -0,0 +1,25 @@
import React, { forwardRef } from "react";
export const Cover = forwardRef(
({ url, index, faded, style, ...props }, ref) => {
const inlineStyles = {
opacity: faded ? "0.2" : "1",
transformOrigin: "0 0",
minHeight: index === 0 ? 300 : 300,
maxWidth: 200,
gridRowStart: index === 0 ? "span" : null,
gridColumnStart: index === 0 ? "span" : null,
backgroundImage: `url("${url}")`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundColor: "grey",
border: "1px solid #CCC",
borderRadius: "10px",
...style,
};
return <div ref={ref} style={inlineStyles} {...props}></div>;
},
);
Cover.displayName = "Cover";

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,98 @@
import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
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 {
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty } from "lodash";
export const Dashboard = (): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchVolumeGroups());
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}),
);
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
comicStatus: "wanted",
}),
);
dispatch(getLibraryStatistics());
}, []);
const recentComics = useSelector(
(state: RootState) => state.fileOps.recentComics,
);
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const volumeGroups = useSelector(
(state: RootState) => state.fileOps.comicVolumeGroups,
);
const libraryStatistics = useSelector(
(state: RootState) => state.comicInfo.libraryStatistics,
);
return (
<div className="container">
<section className="section">
<h1 className="title">Dashboard</h1>
{!isEmpty(recentComics) && !isEmpty(recentComics.docs) ? (
<>
{/* Pull List */}
<PullList issues={recentComics} />
{/* Stats */}
{!isEmpty(libraryStatistics) && (
<LibraryStatistics stats={libraryStatistics} />
)}
{/* Wanted comics */}
{!isEmpty(wantedComics) && (
<WantedComicsList comics={wantedComics} />
)}
{/* Recent imports */}
{!isEmpty(recentComics) && (
<RecentlyImported comicBookCovers={recentComics} />
)}
{/* Volumes */}
{!isEmpty(volumeGroups) && (
<VolumeGroups volumeGroups={volumeGroups} />
)}
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
}
/>
)}
</section>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,117 @@
import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash";
export const LibraryStatistics = (
props: ILibraryStatisticsProps,
): ReactElement => {
// const { stats } = props;
return (
<div className="mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
</h4>
<p className="subtitle is-7">A brief snapshot of your library.</p>
<div className="columns is-multiline">
<div className="column is-narrow is-two-quarter">
<dl className="box">
<dd className="is-size-4">
<span className="has-text-weight-bold">
{props.stats.totalDocuments}
</span>{" "}
files
</dd>
<dd className="is-size-4">
Library size
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)}
</span>
</dd>
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issues.length}
</span>{" "}
tagged with ComicVine
</dd>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
ComicInfo.xml
</span>
</dd>
)}
</dl>
</div>
<div className="p-3 column is-one-quarter">
<dl className="box">
<dd className="is-size-6">
<span className="has-text-weight-bold"></span> Issues
</dd>
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
<dd className="is-size-6">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].fileTypes) &&
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
return (
<span key={idx}>
<span className="has-text-weight-bold">
{fileType.data.length}
</span>
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
{fileType._id}
</span>
</span>
);
})}
</dd>
</dl>
</div>
{/* file types */}
<div className="p-3 column is-two-fifths">
{/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id
}
</span>
{" has the most issues "}
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count
}
</span>
</dd>
)}
<dd className="is-size-6">
<span className="has-text-weight-bold">304</span> Volumes
</dd>
</dl>
</div>
</div>
</div>
);
};
export default LibraryStatistics;

View File

@@ -0,0 +1,164 @@
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../Carda";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom";
type PullListProps = {
issues: any;
};
export const PullList = ({ issues }: PullListProps): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2022-11-15",
pageSize: "15",
currentPage: "1",
}),
);
}, []);
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
);
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
};
*/
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => {
sliderRef.slickNext();
};
const previous = () => {
sliderRef.slickPrev();
};
return (
<>
<div className="content">
<h4 className="title is-4">
<i className="fa-solid fa-splotch"></i> Discover
</h4>
<p className="subtitle is-7">
Pull List aggregated for the week from League Of Comic Geeks
</p>
<div className="field is-grouped">
{/* select week */}
<div className="control">
<div className="select is-small">
<select>
<option>Select Week</option>
<option>With options</option>
</select>
</div>
</div>
{/* See all pull list issues */}
<div className="control">
<Link to={"/pull-list/all/"}>
<button className="button is-small">View all issues</button>
</Link>
</div>
<div className="field has-addons">
<div className="control">
<button className="button is-rounded is-small" onClick={previous}>
<i className="fa-solid fa-caret-left"></i>
</button>
</div>
<div className="control">
<button className="button is-rounded is-small" onClick={next}>
<i className="fa-solid fa-caret-right"></i>
</button>
</div>
</div>
</div>
</div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({issue}, idx) => {
return (
<Card
key={idx}
orientation={"vertical"}
imageUrl={issue.cover}
hasDetails
title={ellipsize(issue.name, 18)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
>
<div className="content">
<div className="control">
<span className="tag">{issue.publisher}</span>
</div>
<div className="mt-2">
<button
className="button is-small is-success is-outlined is-light"
onClick={() => addToLibrary("locg", issue)}
>
<i className="fa-solid fa-plus"></i> Want
</button>
</div>
</div>
</Card>
);
})}
</Slider>
</>
);
};
export default PullList;

View File

@@ -0,0 +1,149 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import { Link } from "react-router-dom";
import ellipsize from "ellipsize";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
type RecentlyImportedProps = {
comicBookCovers: any;
};
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
return (
<>
<div className="content mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
</h4>
<p className="subtitle is-7">
Recent Library activity such as imports, tagging, etc.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comicBookCovers.docs,
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(name, {
comicvine,
comicInfo,
locg,
});
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<React.Fragment key={_id}>
<Card
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : null}
>
<div className="content is-flex is-flex-direction-row">
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="icon custom-icon is-small has-text-danger mr-2">
<img src="/img/missing-file.svg" />
</span>
)}
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<span className="icon custom-icon is-small has-text-danger">
<img
src="/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)}
{/* ComicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img
src="/img/cvlogo.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
{/* metadata card */}
{!isNil(name) ? (
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
<dd className="is-size-9">
<dl>
<span className="icon custom-icon">
<img src={`/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
) : null}
</React.Fragment>
);
},
)}
</Masonry>
</>
);
};

View File

@@ -0,0 +1,64 @@
import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom";
import Masonry from "react-masonry-css";
export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate();
const navigateToVolumes = (row) => {
navigate(`/volumes/all`);
};
return (
<section className="volumes-container mt-4">
<div className="content">
<a className="mb-1" onClick={navigateToVolumes}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-layer-group"></i> Volumes
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
{map(deduplicatedGroups, (data) => {
return (
<div className="stack" key={data._id}>
<img src={data.volumes.image.small_url} />
<div className="content">
<div className="stack-title is-size-8">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 18)}
</Link>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag is-primary is-light">Issues</span>
<span className="tag">{data.volumes.count_of_issues}</span>
</span>
</div>
</div>
</div>
);
})}
</Masonry>
</section>
);
};
export default VolumeGroups;

View File

@@ -0,0 +1,114 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
type WantedComicsListProps = {
comics: any;
};
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate();
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return (
<>
<div className="content mt-6">
<a className="mb-1" onClick={navigateToWantedComics}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-asterisk"></i> Wanted Comics
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">
Comics marked as wanted from various sources.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comics,
({
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/img/locglogo.svg" />
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
);
},
)}
</Masonry>
</>
);
};

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 }
<p>{props.header}</p>
{props.message}
</div>
</article>
);
};
export default ZeroState;
export default ZeroState;

View File

@@ -0,0 +1,94 @@
import React, { ReactElement, useState } from "react";
// https://codesandbox.io/s/dndkit-sortable-image-grid-py6ve?file=/src/Grid.jsx
import {
DndContext,
closestCenter,
MouseSensor,
TouchSensor,
DragOverlay,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
rectSortingStrategy,
} from "@dnd-kit/sortable";
import { Grid } from "./Grid";
import { SortableCover } from "./SortableCover";
import { Cover } from "./Cover";
import { map } from "lodash";
export const DnD = (data) => {
const [items, setItems] = useState(data.data);
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
function handleDragStart(event) {
setActiveId(event.active.id);
}
function handleDragEnd(event) {
const { active, over } = event;
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
setActiveId(null);
}
function handleDragCancel() {
setActiveId(null);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<Grid columns={4}>
{map(items, (url, index) => {
return (
<div>
<SortableCover key={url} url={url} index={index} />
<div
className="mt-2 mb-2"
onClick={(e) => data.onClickHandler(url)}
>
<div className="box p-2 pl-3 control-palette">
<span className="tag is-warning mr-2">{index}</span>
<span className="icon is-small mr-2">
<i className="fa-solid fa-vial"></i>
</span>
<span className="icon is-small mr-2">
<i className="fa-solid fa-bullseye"></i>
</span>
<span className="icon is-small mr-2">
<i className="fa-regular fa-trash-can"></i>
</span>
</div>
</div>
</div>
);
})}
</Grid>
</SortableContext>
<DragOverlay adjustScale={true}>
{activeId ? (
<Cover url={activeId} index={items.indexOf(activeId)} />
) : null}
</DragOverlay>
</DndContext>
);
};
export default DnD;

View File

@@ -0,0 +1,99 @@
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { isEmpty, isNil, isUndefined } from "lodash";
import { searchIssue } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel";
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 => {
console.log(bundle);
return <>
<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">
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Bundle ID</th>
</tr>
{bundle.acquisition.directconnect.downloads.map((bundle) => {
return(<tr>
<td>{bundle.name}</td>
<td>{bundle.size}</td>
<td>{bundle.type.str}</td>
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
</tr>)
})}
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</>
})}
</div>
</div>
</section>
</div> : <div>asd</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 "../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) => (
<MetadataPanel
data={result}
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

@@ -0,0 +1,17 @@
import React from "react";
export function Grid({ children, columns }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 200px)`,
columnGap: 1,
gridGap: 10,
padding: 10,
}}
>
{children}
</div>
);
}

View File

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

View File

@@ -0,0 +1,239 @@
import React, { useMemo, ReactElement, useCallback, 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 { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
*/
export const Library = (): ReactElement => {
const searchResults = useSelector(
(state: RootState) => state.fileOps.libraryComics,
);
const searchError = useSelector(
(state: RootState) => state.fileOps.librarySearchError,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 15,
from: 0,
},
type: "all",
trigger: "libraryPage"
},
),
);
}, []);
// programatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
};
const ComicInfoXML = (value) => {
return value.data ? (
<div className="comicvine-metadata">
<dl>
<span className="tags has-addons is-size-7">
<span className="tag">Series</span>
<span className="tag is-warning is-light">
{ellipsize(value.data.series[0], 25)}
</span>
</span>
</dl>
<dl>
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Pages</span>
<span className="tag is-info is-light has-text-weight-bold">
{value.data.pagecount[0]}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Issue</span>
{!isNil(value.data.number) && (
<span className="tag has-text-weight-bold is-success is-light">
{parseInt(value.data.number[0], 10)}
</span>
)}
</span>
</div>
</div>
</dl>
</div>
) : null;
};
const WantedStatus = ({ value }) => {
return !value ? <span className="tag is-info is-light">Wanted</span> : 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",
align: "center",
minWidth: 250,
cell: info =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : (
<span className="tag mt-5">No ComicInfo.xml</span>
),
},
],
},
{
header: "Additional Metadata",
columns: [
{
header: "Publisher",
accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation",
cell: info => {
return (
!isNil(info.getValue()) && (
<h6 className="is-size-7 has-text-weight-bold">
{info.getValue().publisher.name}
</h6>
)
);
},
},
{
header: "Something",
accessorKey: "_source.acquisition.source.wanted",
cell: info => {
!isUndefined(info.getValue()) ?
<WantedStatus value={info.getValue().toString()} /> : "Nothing";
},
},
],
}
], []);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17;
} else {
from = (pageIndex - 1) * pageSize + 2 - 16;
}
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from,
},
type: "all",
trigger: "libraryPage"
},
),
);
}, []);
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Library</h1>
</div>
{!isUndefined(searchResults.hits) && (
<div>
<div className="library">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits?.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
/>
</div>
</div>
)}
</div>
</section>
);
};
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 "../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="/dist/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,64 @@
import React, { ReactElement, useCallback } from "react";
import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (): ReactElement => {
const dispatch = useDispatch();
const handleSubmit = useCallback((e) => {
dispatch(
searchIssue(
{
query: {
volumeName: e.search,
},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
},
),
);
}, []);
return (
<div className="box">
<Form
onSubmit={handleSubmit}
initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<div className="field is-grouped">
<div className="control search is-expanded">
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<div className="control">
<button className="button is-medium" type="submit">
Search
</button>
</div>
</div>
</form>
)}
/>
</div>
);
};
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,57 +1,124 @@
import React, { useEffect } from "react";
import { map } from "lodash";
import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../actions/comicinfo.actions";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
interface MatchResultProps {
matchData: any;
comicObjectId: string;
}
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
};
export const MatchResult = (props: MatchResultProps) => {
const dispatch = useDispatch();
const applyCVMatch = useCallback(
(match, comicObjectId) => {
dispatch(applyComicVineMatch(match, comicObjectId));
},
[dispatch],
);
return (
<>
<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>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
issueDescription = convert(match.description, {
baseElements: {
selectors: ["p"],
},
});
}
return (
<div className="search-result mb-4" key={idx}>
<div className="columns">
<div className="column is-one-fifth">
<img
className="cover-image"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div className="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 className="search-result-details column">
<div className="is-size-5">{match.name}</div>
<div className="field is-grouped is-grouped-multiline mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary">
{match.issue_number}
</span>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div>
<div className="is-size-7">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="vertical-line"></div>
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img
src={match.volumeInformation.results.image.icon_url}
className="cover-image"
onError={handleBrokenImage}
/>
</div>
<div className="column">
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Total Issues</span>
<span className="tag is-warning">
{match.volumeInformation.results.count_of_issues}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<button
className="button is-normal is-outlined is-primary is-light is-pulled-right"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="icon is-size-5">
<i className="fas fa-clipboard-check"></i>
</span>
<span>Apply Match</span>
</button>
</div>
</div>
</div>
);
})}
</>
);
};

View File

@@ -1,22 +1,26 @@
import React from "react";
import { useSelector } from "react-redux";
import { SearchBar } from "./GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { RootState } from "threetwo-ui-typings";
import { useSelector } from "react-redux";
import { isUndefined } from "lodash";
const Navbar: React.FunctionComponent = (props) => {
const socketConnection = useSelector((state: RootState) => state.fileOps);
const downloadProgressTick = useSelector(
(state: RootState) => state.airdcpp.downloadProgressData,
);
return (
<nav className="navbar ">
<nav className="navbar is-fixed-top">
<div className="navbar-brand">
<a className="navbar-item" href="http://bulma.io">
<Link to="/" className="navbar-item">
<img
src="http://bulma.io/images/bulma-logo.png"
alt="Bulma: a modern CSS framework based on Flexbox"
src="/img/threetwo.svg"
alt="ThreeTwo! A comic book curator"
width="112"
height="28"
/>
</a>
</Link>
<a className="navbar-item is-hidden-desktop">
<span className="icon">
@@ -47,40 +51,38 @@ const Navbar: React.FunctionComponent = (props) => {
Import
</Link>
<Link to="/library" className="navbar-item">
Library
</Link>
<Link to="/downloads" className="navbar-item">
Downloads
</Link>
<SearchBar />
<Link to="/search" className="navbar-item">
Search ComicVine
</Link>
</div>
<div className="navbar-end">
<a className="navbar-item is-hidden-desktop-only"></a>
<div className="navbar-item has-dropdown is-hoverable">
<a
className="navbar-link is-active"
href="/documentation/overview/start/"
>
Docs
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i>
{downloadProgressTick && (
<div className="pulsating-circle"></div>
)}
</a>
<div className="navbar-dropdown ">
<a className="navbar-item " href="/documentation/overview/start/">
Overview
</a>
<a
className="navbar-item is-active"
href="http://bulma.io/documentation/components/breadcrumb/"
>
Components
</a>
<hr className="navbar-divider" />
<div className="navbar-item">
<div>
<p className="is-size-6-desktop">
<strong className="has-text-info">0.5.1</strong>
</p>
<small>
<a className="bd-view-all-versions" href="/versions">
View all versions
</a>
</small>
</div>
{!isUndefined(downloadProgressTick) ? (
<div className="navbar-dropdown download-progress-meter">
<a className="navbar-item">
<DownloadProgressTick data={downloadProgressTick} />
</a>
</div>
</div>
) : null}
</div>
<div className="navbar-item has-dropdown is-hoverable is-mega">
<div className="navbar-link flex">Blog</div>
@@ -183,20 +185,13 @@ const Navbar: React.FunctionComponent = (props) => {
</div>
</div>
</div>
</div>
<div className="navbar-end">
<a className="navbar-item is-hidden-desktop-only"></a>
<div className="navbar-item">
<div className="field is-grouped">
<p className="control">
{socketConnection.socketConnected ? (
<span className="icon is-small has-text-success">
<i className="fas fa-plug"></i>
</span>
) : null}
<Link to="/settings" className="navbar-item">
Settings
</Link>
</p>
<p className="control">Settings</p>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,103 @@
import React, { useState, ReactElement } from "react";
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
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");
const settingsContent = [
{
id: "adc-hubs",
content: <div key="adc-hubs">{<AirDCPPHubsForm />}</div>,
},
{
id: "adc-connection",
content: (
<div key="adc-connection">
<AirDCPPSettingsForm />
</div>
),
},
{
id: "flushdb",
content: (
<div key="flushdb">
<SystemSettingsForm />
</div>
),
},
];
return (
<section className="container">
<div className="columns">
<div className="section column is-one-quarter">
<h1 className="title">Settings</h1>
<aside className="menu">
{map(settingsObject, (settingObject, idx) => {
return (
<div key={idx}>
<p className="menu-label">{settingObject.category}</p>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul className="menu-list" key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx}>
<a
className={
item.id.toString() === active ? "is-active" : ""
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul>
{map(item.children, (item, idx) => (
<li key={item.id}>
<a
className={
item.id.toString() === active
? "is-active"
: ""
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
</div>
{/* content for settings */}
<div className="section column is-half mt-6">
<div className="content">
{map(settingsContent, ({ id, content }) =>
active === id ? content : null,
)}
</div>
</div>
</div>
</section>
);
};
export default Settings;

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Cover } from "./Cover";
export const SortableCover = (props) => {
const sortable = useSortable({ id: props.url });
const {
attributes,
listeners,
isDragging,
setNodeRef,
transform,
transition,
} = sortable;
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<Cover
ref={setNodeRef}
style={style}
{...props}
{...attributes}
{...listeners}
/>
);
};

View File

@@ -0,0 +1,67 @@
import React, { ReactElement, useCallback } from "react";
import { flushDb } from "../../actions/settings.actions";
import { useDispatch, useSelector } from "react-redux";
export const SystemSettingsForm = (): ReactElement => {
const dispatch = useDispatch();
const isSettingsCallInProgress = useSelector(
(state: RootState) => state.settings.inProgress,
);
const flushDatabase = useCallback(() => {
dispatch(flushDb());
}, []);
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={
isSettingsCallInProgress
? "button is-danger is-loading"
: "button is-danger"
}
onClick={flushDatabase}
>
<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 "../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 "../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,185 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import Card from "../Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { isUndefined } from "lodash";
export const Volumes = (props): ReactElement => {
const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumes",
trigger: "volumesPage",
},
),
);
}, []);
const columnData = useMemo(
() => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorKey: "_source",
cell: (row) => {
const foo = row.getValue();
return (
<div className="columns">
<div className="column">
<div className="comic-detail issue-metadata">
<dl>
<dd>
<div className="columns mt-2">
<div className="">
<Card
imageUrl={
foo.sourcedMetadata.comicvine.volumeInformation
.image.thumb_url
}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
</div>
</div>
);
},
},
{
header: "Download Status",
columns: [
{
header: "Files",
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: "Type",
id: "Air",
},
{
header: "Type",
id: "dcc",
},
],
},
],
[],
);
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Volumes</h1>
</div>
{!isUndefined(volumes.hits) && (
<div>
<div className="library">
<T2Table
sourceData={volumes?.hits?.hits}
totalPages={volumes.hits.hits.length}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
}}
columns={columnData}
/>
</div>
</div>
)}
</div>
</section>
);
};
export default Volumes;

View File

@@ -0,0 +1,175 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table";
import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
export const WantedComics = (props): ReactElement => {
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 25,
from: 0,
},
type: "wanted",
trigger: "wantedComicsPage"
},
),
);
}, []);
const columnData = [
{
header: "Comic Information",
columns: [
{
header: "Details",
id: "comicDetails",
minWidth: 350,
accessorFn: data => data,
cell: (value) => <MetadataPanel data={value.getValue()} />,
},
],
},
{
header: "Download Status",
columns: [
{
header: "Files",
accessorKey: "acquisition",
align: "right",
cell: props => {
const { directconnect: { downloads } } = props.getValue();
return (
<div
style={{
display: "flex",
// flexDirection: "column",
justifyContent: "center",
}}
>
{downloads.length > 0 ? (
<span className="tag is-warning">
{downloads.length}
</span>
) : null}
</div>
);
},
},
{
header: "Download Details",
id: "downloadDetails",
accessorKey: "acquisition",
cell: data => <ol>
{data.getValue().directconnect.downloads.map(download => {
return <li className="is-size-7">{download.name}</li>;
})}
</ol>
},
{
header: "Type",
id: "dcc",
},
],
},
];
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "wanted",
trigger: "wantedComicsPage",
},
),
);
}, []);
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
**/
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17;
} else {
from = (pageIndex - 1) * pageSize + 2 - 16;
}
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from,
},
type: "wanted",
trigger: "wantedComicsPage"
},
),
);
}, []);
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Wanted Comics</h1>
</div>
{!isEmpty(wantedComics) && (
<div>
<div className="library">
<T2Table
sourceData={wantedComics}
totalPages={wantedComics.length}
columns={columnData}
paginationHandlers={{
nextPage: nextPage,
previousPage: previousPage,
}}
// rowClickHandler={navigateToComicDetail}
/>
{/* pagination controls */}
</div>
</div>
)}
</div>
</section>
);
};
export default WantedComics;

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useRef } from "react";
export const Canvas = (data) => {
const { colorHistogramData } = data.data;
console.log(data);
const width = 559;
const height = 200;
const pixelRatio = window.devicePixelRatio;
const canvas = useRef(null);
useEffect(() => {
const context = canvas.current.getContext("2d");
context.scale(pixelRatio, pixelRatio);
const guideHeight = 8;
const startY = height - guideHeight;
const dx = width / 256;
const dy = startY / colorHistogramData.maxBrightness;
context.lineWidth = dx;
context.fillStyle = "transparent";
context.fillRect(0, 0, width, height);
for (let i = 0; i < 256; i++) {
const x = i * dx;
// Red
context.strokeStyle = "rgba(220,0,0,0.5)";
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, startY - colorHistogramData.r[i] / 10);
context.closePath();
context.stroke();
// Green
context.strokeStyle = "rgba(0,210,0,0.5)";
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, startY - colorHistogramData.g[i] / 10);
context.closePath();
context.stroke();
// Blue
context.strokeStyle = "rgba(0,0,255,0.5)";
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, startY - colorHistogramData.b[i] / 10);
context.closePath();
context.stroke();
// Guide
context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, height);
context.closePath();
context.stroke();
}
});
const dw = Math.floor(pixelRatio * width);
const dh = Math.floor(pixelRatio * height);
const style = { width, height };
return <canvas ref={canvas} width={dw} height={dh} style={style} />;
};
export default Canvas;

View File

@@ -0,0 +1,228 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes";
import { Card } from "../Carda";
import { convert } from "html-to-text";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash";
interface IMetadatPanelProps {
value: any;
children: any;
imageStyle: any;
titleStyle: any;
tagsStyle: any;
containerStyle: any;
}
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
const {
rawFileDetails,
inferredMetadata,
sourcedMetadata: { comicvine, locg },
} = props.data;
const { issueName, url, objectReference } = determineCoverFile({
comicvine,
locg,
rawFileDetails,
});
const metadataContentPanel = [
{
name: "rawFileDetails",
content: () => (
<dl>
<dt>
<h6
className="name has-text-weight-medium mb-1"
style={props.titleStyle}
>
{issueName}
</h6>
</dt>
<dd className="is-size-7">
Is a part of{" "}
<span className="has-text-weight-semibold">
{inferredMetadata.issue.name}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.extension}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
</div>
<div className="control">
{inferredMetadata.issue.number && (
<div className="tags has-addons">
<span className="tag is-light" style={props.tagsStyle}>
Issue #
</span>
<span className="tag is-warning" style={props.tagsStyle}>
{inferredMetadata.issue.number}
</span>
</div>
)}
</div>
</div>
</dd>
</dl>
),
},
{
name: "comicvine",
content: () =>
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation) && (
<dl>
<dt>
<h6
className="name has-text-weight-medium mb-1"
style={props.titleStyle}
>
{ellipsize(issueName, 18)}
</h6>
</dt>
<dd>
<span className="is-size-7">
Is a part of{" "}
<span className="has-text-weight-semibold">
{comicvine.volumeInformation.name}
</span>
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(comicvine.description, {
baseElements: {
selectors: ["p"],
},
}),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{comicvine.volumeInformation.start_year}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{comicvine.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
</dd>
</dl>
),
},
{
name: "locg",
content: () => (
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{ellipsize(issueName, 28)}
</h6>
</dt>
<dd className="is-size-7">
<span>{ellipsize(locg.description, 120)}</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
{locg.price}
</span>
<span className="tag is-success is-light">{locg.pulls}</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag is-primary is-light">rating</span>
<span className="tag is-info is-light">{locg.rating}</span>
</div>
</div>
</div>
</dd>
</dl>
),
},
];
// Find the panel to display
const metadataPanel = find(metadataContentPanel, {
name: objectReference,
});
return (
<div className="column" style={props.containerStyle}>
<div className="comic-detail issue-metadata">
<dl>
<dd>
<div className="columns mt-2">
<div className="column is-3">
<Card
imageUrl={url}
orientation={"vertical"}
hasDetails={false}
imageStyle={props.imageStyle}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">{metadataPanel.content()}</div>
</div>
</dd>
</dl>
</div>
</div>
);
};
export default MetadataPanel;

View File

@@ -0,0 +1,164 @@
import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
PaginationState,
} from "@tanstack/react-table";
export const T2Table = (tableOptions): ReactElement => {
const { sourceData, columns, paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler } =
tableOptions;
const [{ pageIndex, pageSize }, setPagination] =
useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
console.log(sourceData)
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
);
/**
* Pagination control to move forward one page
* @returns void
*/
const goToNextPage = () => {
setPagination({
pageIndex: pageIndex + 1,
pageSize,
});
nextPage(pageIndex, pageSize);
}
/**
* Pagination control to move backward one page
* @returns void
**/
const goToPreviousPage = () => {
setPagination({
pageIndex: pageIndex - 1,
pageSize,
});
previousPage(pageIndex, pageSize);
}
const table = useReactTable({
data: sourceData,
columns,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1,
state: {
pagination,
},
onPaginationChange: setPagination,
});
return (
<>
<div className="columns table-controls">
{/* Search bar */}
<div className="column is-half">
<SearchBar />
</div>
{/* pagination controls */}
<nav className="pagination columns">
<div className="mr-4 has-text-weight-semibold has-text-left">
<p className="is-size-5">Page {pageIndex} of {Math.ceil(totalPages / pageSize)}</p>
{/* <p>{totalPages} comics in all</p> */}
</div>
<div className="field has-addons">
<p className="control">
<div className="button" onClick={() => goToPreviousPage()}> <i className="fas fa-chevron-left"></i></div>
</p>
<p className="control">
<div className="button" onClick={() => goToNextPage()}> <i className="fas fa-chevron-right"></i> </div>
</p>
<div className="field has-addons ml-5">
<p className="control">
<button className="button">
<span className="icon is-small">
<i className="fa-solid fa-list"></i>
</span>
</button>
</p>
<p className="control">
<button className="button">
<Link to="/library-grid">
<span className="icon is-small">
<i className="fa-solid fa-image"></i>
</span>
</Link>
</button>
</p>
</div>
</div>
</nav>
</div>
<table className="table is-hoverable">
<thead>
{table.getHeaderGroups().map((headerGroup, idx) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, idx) => (
<th
key={header.id}
colSpan={header.colSpan}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, idx) => {
return (
<tr
key={row.id}
onClick={() => rowClickHandler(row)}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</>
);
};
T2Table.propTypes = {
sourceData: PropTypes.array,
totalPages: PropTypes.number,
columns: PropTypes.array,
paginationHandlers: PropTypes.shape({
nextPage: PropTypes.func,
previousPage: PropTypes.func,
}),
rowClickHandler: PropTypes.func,
};
export default T2Table;

View File

@@ -6,14 +6,133 @@ export const CV_CLEANUP = "CV_CLEANUP";
export const CV_API_GENERIC_FAILURE = "CV_API_GENERIC_FAILURE";
export const IMS_COMICBOOK_METADATA_FETCHED = "IMS_SOCKET_DATA_FETCHED";
export const IMS_SOCKET_CONNECTION_CONNECTED =
"IMS_SOCKET_CONNECTION_CONNECTED";
export const IMS_SOCKET_CONNECTION_DISCONNECTED =
"IMS_SOCKET_CONNECTION_DISCONNECTED";
export const IMS_SOCKET_ERROR = "IMS_SOCKET_ERROR";
export const IMS_RAW_IMPORT_SUCCESSFUL = "IMS_RAW_IMPORT_SUCCESSFUL";
export const IMS_RAW_IMPORT_FAILED = "IMS_RAW_IMPORT_FAILED";
// Library service generic action types
export const LS_IMPORT_CALL_IN_PROGRESS = "LS_IMPORT_CALL_IN_PROGRESS";
// Library import bull mq queue control
export const LS_TOGGLE_IMPORT_QUEUE = "LS_TOGGLE_IMPORT_QUEUE";
export const LS_QUEUE_DRAINED = "LS_QUEUE_DRAINED";
// ComicVine Metadata
export const IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS =
"IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS";
export const IMS_CV_METADATA_IMPORT_SUCCESSFUL =
"IMS_CV_METADATA_IMPORT_SUCCESSFUL";
export const IMS_CV_METADATA_IMPORT_FAILED = "IMS_CV_METADATA_IMPORT_FAILED";
export const IMS_RECENT_COMICS_FETCHED = "IMS_RECENT_COMICS_FETCHED";
export const IMS_DATA_FETCH_ERROR = "IMS_DATA_FETCH_ERROR";
// Weekly pull list
export const CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS =
"CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS";
export const CV_WEEKLY_PULLLIST_FETCHED = "CV_WEEKLY_PULLLIST_FETCHED";
export const CV_WEEKLY_PULLLIST_ERROR = "CV_WEEKLY_PULLLIST_ERROR";
// Single or multiple comic book mongo objects
export const IMS_COMIC_BOOK_DB_OBJECT_FETCHED =
"IMS_COMIC_BOOK_DB_OBJECT_FETCHED";
export const IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED =
"IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED";
export const IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS =
"IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS";
export const IMS_COMIC_BOOK_DB_OBJECT_CALL_FAILED =
"IMS_COMIC_BOOK_DB_OBJECT_CALL_FAILED";
// wanted comics from CV, LoCG and other sources
export const IMS_WANTED_COMICS_FETCHED = "IMS_WANTED_COMICS_FETCHED";
// volume groups
export const IMS_COMIC_BOOK_GROUPS_FETCHED = "IMS_COMIC_BOOK_GROUPS_FETCHED";
export const IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS =
"IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS";
export const IMS_COMIC_BOOK_GROUPS_CALL_FAILED =
"IMS_COMIC_BOOK_GROUPS_CALL_FAILED";
export const VOLUMES_FETCHED="VOLUMES_FETCHED";
// search results from the Search service
export const SS_SEARCH_RESULTS_FETCHED = "SS_SEARCH_RESULTS_FETCHED";
export const SS_SEARCH_RESULTS_FETCHED_SPECIAL = "SS_SEARCH_RESULTS_FETCHED_SPECIAL";
export const SS_SEARCH_IN_PROGRESS = "SS_SEARCH_IN_PROGRESS";
export const SS_SEARCH_FAILED = "SS_SEARCH_FAILED";
// issues for a given volume
export const CV_ISSUES_METADATA_CALL_IN_PROGRESS =
"CV_ISSUES_METADATA_CALL_IN_PROGRESS";
export const CV_ISSUES_METADATA_FETCH_SUCCESS =
"CV_ISSUES_METADATA_FETCH_SUCCESS";
export const CV_ISSUES_METADATA_FETCH_FAILED =
"CV_ISSUES_METADATA_FETCH_FAILED";
export const CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS =
"CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS";
export const CV_ISSUES_FOR_VOLUME_IN_LIBRARY_UPDATED =
"CV_ISSUES_FOR_VOLUME_IN_LIBRARY_UPDATED";
export const CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED =
"CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED";
// extracted comic archive
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS =
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS";
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS =
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS";
export const IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_FAILED =
"IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_FAILED";
// Image file stats
export const IMG_ANALYSIS_CALL_IN_PROGRESS = "IMG_ANALYSIS_CALL_IN_PROGRESS";
export const IMG_ANALYSIS_DATA_FETCH_SUCCESS =
"IMG_ANALYSIS_DATA_FETCH_SUCCESS";
export const IMG_ANALYSIS_DATA_FETCH_ERROR = "IMG_ANALYSIS_DATA_FETCH_ERROR";
// library statistics
export const LIBRARY_STATISTICS_CALL_IN_PROGRESS =
"LIBRARY_STATISTICS_CALL_IN_PROGRESS";
export const LIBRARY_STATISTICS_FETCHED = "LIBRARY_STATISTICS_FETCHED";
export const LIBRARY_STATISTICS_FETCH_ERROR = "LIBRARY_STATISTICS_FETCH_ERROR";
// fileops cleanup
export const FILEOPS_STATE_RESET = "FILEOPS_STATE_RESET";
// AirDC++
export const AIRDCPP_SEARCH_IN_PROGRESS = "AIRDCPP_SEARCH_IN_PROGRESS";
export const AIRDCPP_SEARCH_RESULTS_ADDED = "AIRDCPP_SEARCH_RESULTS_ADDED";
export const AIRDCPP_SEARCH_RESULTS_UPDATED = "AIRDCPP_SEARCH_RESULTS_UPDATED";
export const AIRDCPP_SEARCH_COMPLETE = "AIRDCPP_SEARCH_COMPLETE";
// AirDC++ related library query for issues with bundles associated with them
export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES";
export const AIRDCPP_HUB_SEARCHES_SENT = "AIRDCPP_HUB_SEARCHES_SENT";
export const AIRDCPP_RESULT_DOWNLOAD_INITIATED =
"AIRDCPP_RESULT_DOWNLOAD_INITIATED";
export const AIRDCPP_FILE_DOWNLOAD_COMPLETED =
"AIRDCPP_FILE_DOWNLOAD_COMPLETED";
export const LS_SINGLE_IMPORT = "LS_SINGLE_IMPORT";
export const AIRDCPP_BUNDLES_FETCHED = "AIRDCPP_BUNDLES_FETCHED";
export const AIRDCPP_DOWNLOAD_PROGRESS_TICK = "AIRDCPP_DOWNLOAD_PROGRESS_TICK";
// Transfers
export const AIRDCPP_TRANSFERS_FETCHED = "AIRDCPP_TRANSFERS_FETCHED";
// Comics marked as "wanted"
export const WANTED_COMICS_FETCHED = "WANTED_COMICS_FETCHED";
// LIBRARY SOCKET ENDPOINT
export const LS_IMPORT = "LS_IMPORT";
export const LS_COVER_EXTRACTED = "LS_COVER_EXTRACTED";
export const LS_COMIC_ADDED = "LS_COMIC_ADDED";
// Settings
export const SETTINGS_CALL_IN_PROGRESS = "SETTINGS_CALL_IN_PROGRESS";
export const SETTINGS_OBJECT_FETCHED = "SETTINGS_OBJECT_FETCHED";
export const SETTINGS_CALL_FAILED = "SETTINGS_CALL_FAILED";
export const SETTINGS_OBJECT_DELETED = "SETTINGS_OBJECT_DELETED";
export const SETTINGS_DB_FLUSH_SUCCESS = "SETTINGS_DB_FLUSH_SUCCESS";
// Metron Metadata
export const METRON_DATA_FETCH_SUCCESS = "METRON_DATA_FETCH_SUCCESS";
export const METRON_DATA_FETCH_IN_PROGRESS = "METRON_DATA_FETCH_IN_PROGRESS";
export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR";

View File

@@ -1,4 +1,78 @@
export const COMICBOOKINFO_SERVICE_URI =
"http://localhost:6050/api/comicbookinfo/";
export const API_BASE_URI = "http://localhost:8050/api/";
export const SOCKET_BASE_URI = "ws://localhost:3000/";
export const hostURIBuilder = (options: Record<string, string>): string => {
return (
options.protocol +
"://" +
options.host +
":" +
options.port +
options.apiPath
);
};
export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "8050",
apiPath: "/",
});
export const COMICVINE_SERVICE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3080",
apiPath: "/api/comicvine",
});
export const METRON_SERVICE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3080",
apiPath: "/api/metron",
});
export const API_BASE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "8050",
apiPath: "/api",
});
export const LIBRARY_SERVICE_HOST = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: ``,
});
export const LIBRARY_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: "/api/library",
});
export const SEARCH_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: "/api/search",
});
export const SETTINGS_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: "/api/settings",
});
export const IMAGETRANSFORMATION_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: "/api/imagetransformation",
});
export const SOCKET_BASE_URI = hostURIBuilder({
protocol: "ws",
host: process.env.UNDERLYING_HOSTNAME || "localhost",
port: "3001",
apiPath: `/`,
});

View File

@@ -0,0 +1,11 @@
const MODULE_URL = "search";
export default {
MODULE_URL: MODULE_URL,
INSTANCES_URL: MODULE_URL,
SEARCH_TYPES_URL: MODULE_URL + "/types",
SEARCH_TYPES_UPDATED: "search_types_updated",
DEFAULT_SEARCH_TYPE: "any",
};

View File

@@ -0,0 +1,84 @@
[
{
"id": 1,
"category": "general",
"displayName": "General",
"children": [
{
"id": "gen-db",
"displayName": "Dashboard"
},
{
"id": "gen-gls",
"displayName": "Global Search"
}
]
},
{
"id": 2,
"category": "acquisition",
"displayName": "Acquisition",
"children": [
{
"id": "adc",
"displayName": "AirDC++",
"children": [
{
"id": "adc-connection",
"displayName": "Connection"
},
{
"id": "adc-hubs",
"displayName": "Hubs"
},
{
"id": "adc-add-config",
"displayName": "Additional Configuration"
}
]
}
]
},
{
"id": 3,
"category": "comicvine",
"displayName": "Comic Vine",
"children": [
{
"id": "api",
"displayName": "API"
},
{
"id": "options",
"displayName": "Options"
}
]
},
{
"id": 4,
"category": "system",
"displayName": "System",
"children": [
{
"id": "flushdb",
"displayName": "Flush DB & Temporary folders"
}
]
},
{
"id": 5,
"category": "acknowledgments",
"displayName": "Acknowledgments",
"children": [
{
"id": "testers",
"displayName": "Testers"
},
{
"id": "software",
"displayName": "Software"
}
]
}
]

View File

@@ -0,0 +1,86 @@
import { isEmpty, isUndefined } from "lodash";
import React, { createContext, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getSettings } from "../actions/settings.actions";
import AirDCPPSocket from "../services/DcppSearchService";
const AirDCPPSocketContextProvider = ({ children }) => {
// setter for settings for use in the context consumer
const setSettings = (settingsObject) => {
persistSettings({
...airDCPPState,
airDCPPState: {
settings: settingsObject,
socket: {},
socketConnectionInformation: {},
},
});
};
// 1. default zero-state for AirDC++ configuration
const initState = {
airDCPPState: {
settings: {},
socket: {},
socketConnectionInformation: {},
},
setSettings: setSettings,
};
const dispatch = useDispatch();
const [airDCPPState, persistSettings] = useState(initState);
const airDCPPSettings = useSelector(
(state: RootState) => state.settings.data,
);
// 1. get settings from mongo
useEffect(() => {
dispatch(getSettings());
}, []);
// 2. If available, init AirDC++ Socket with those settings
useEffect(() => {
if (!isEmpty(airDCPPSettings)) {
initializeAirDCPPSocket(airDCPPSettings);
}
}, [airDCPPSettings]);
// Method to init AirDC++ Socket with supplied settings
const initializeAirDCPPSocket = async (configuration) => {
console.log("[AirDCPP]: Initializing socket...");
const {
directConnect: {
client: { host },
},
} = configuration;
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${host.protocol}`,
hostname: `${host.hostname}:${host.port}`,
});
const socketConnectionInformation = await initializedAirDCPPSocket.connect(
`${host.username}`,
`${host.password}`,
true,
);
persistSettings({
...airDCPPState,
airDCPPState: {
settings: configuration,
socket: initializedAirDCPPSocket,
socketConnectionInformation,
},
});
};
// the Provider gives access to the context to its children
return (
<AirDCPPSocketContext.Provider value={airDCPPState}>
{children}
</AirDCPPSocketContext.Provider>
);
};
const AirDCPPSocketContext = createContext({
airDCPPState: {},
saveSettings: () => {},
});
export { AirDCPPSocketContext, AirDCPPSocketContextProvider };

View File

@@ -1,18 +1,18 @@
import * as React from "react";
import React from "react";
import { render } from "react-dom";
import { Provider } from "react-redux";
import { ConnectedRouter } from "connected-react-router";
import configureStore, { history } from "./store/index";
import { Provider, connect } from "react-redux";
import { HistoryRouter as Router } from "redux-first-history/rr6";
import { store, history } from "./store/index";
import { createRoot } from "react-dom/client";
import App from "./components/App";
const store = configureStore({});
const rootEl = document.getElementById("root");
const root = createRoot(rootEl);
render(
root.render(
<Provider store={store}>
<ConnectedRouter history={history}>
<Router history={history}>
<App />
</ConnectedRouter>
</Router>
</Provider>,
rootEl,
);

View File

@@ -0,0 +1,114 @@
import {
AIRDCPP_SEARCH_IN_PROGRESS,
AIRDCPP_SEARCH_RESULTS_ADDED,
AIRDCPP_SEARCH_RESULTS_UPDATED,
AIRDCPP_HUB_SEARCHES_SENT,
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
AIRDCPP_BUNDLES_FETCHED,
AIRDCPP_TRANSFERS_FETCHED,
LIBRARY_ISSUE_BUNDLES,
} from "../constants/action-types";
import { LOCATION_CHANGE } from "redux-first-history";
import { isNil, isUndefined } from "lodash";
import { difference } from "../shared/utils/object.utils";
const initialState = {
searchResults: [],
isAirDCPPSearchInProgress: false,
searchInfo: null,
searchInstance: null,
downloadResult: null,
bundleDBImportResult: null,
downloadFileStatus: {},
bundles: [],
transfers: [],
};
function airdcppReducer(state = initialState, action) {
switch (action.type) {
case AIRDCPP_SEARCH_RESULTS_ADDED:
return {
...state,
searchResults: [...state.searchResults, action.groupedResult],
isAirDCPPSearchInProgress: true,
};
case AIRDCPP_SEARCH_RESULTS_UPDATED:
const bundleToUpdateIndex = state.searchResults.findIndex(
(bundle) => bundle.result.id === action.groupedResult.result.id,
);
const updatedState = [...state.searchResults];
if (
!isNil(
difference(updatedState[bundleToUpdateIndex], action.groupedResult),
)
) {
updatedState[bundleToUpdateIndex] = action.groupedResult;
}
return {
...state,
searchResults: updatedState,
};
case AIRDCPP_SEARCH_IN_PROGRESS:
return {
...state,
isAirDCPPSearchInProgress: true,
};
case AIRDCPP_HUB_SEARCHES_SENT:
return {
...state,
isAirDCPPSearchInProgress: false,
searchInfo: action.searchInfo,
searchInstance: action.instance,
};
case AIRDCPP_RESULT_DOWNLOAD_INITIATED:
return {
...state,
downloadResult: action.downloadResult,
bundleDBImportResult: action.bundleDBImportResult,
};
case AIRDCPP_DOWNLOAD_PROGRESS_TICK:
return {
...state,
downloadProgressData: action.downloadProgressData,
};
case AIRDCPP_BUNDLES_FETCHED:
return {
...state,
bundles: action.bundles,
};
case LIBRARY_ISSUE_BUNDLES:
return {
...state,
issue_bundles: action.issue_bundles,
};
case AIRDCPP_FILE_DOWNLOAD_COMPLETED:
console.log("COMPLETED", action);
return {
...state,
};
case AIRDCPP_TRANSFERS_FETCHED:
return {
...state,
transfers: action.bundles,
};
case LOCATION_CHANGE:
return {
searchResults: [],
isAirDCPPSearchInProgress: false,
searchInfo: null,
searchInstance: null,
downloadResult: null,
bundleDBImportResult: null,
// bundles: [],
};
default:
return state;
}
}
export default airdcppReducer;

View File

@@ -1,37 +0,0 @@
import {
CV_API_CALL_IN_PROGRESS,
CV_SEARCH_SUCCESS,
CV_CLEANUP,
} from "../constants/action-types";
const initialState = {
searchResults: [],
searchQuery: {},
inProgress: false,
};
function comicinfoReducer(state = initialState, action) {
switch (action.type) {
case CV_API_CALL_IN_PROGRESS:
return {
...state,
inProgress: true,
};
case CV_SEARCH_SUCCESS:
console.log("ACTION", action);
return {
...state,
searchResults: action.searchResults,
searchQuery: action.searchQueryObject,
inProgress: false,
};
case CV_CLEANUP:
return {
searchResults: [],
searchQuery: {},
};
default:
return state;
}
}
export default comicinfoReducer;

View File

@@ -0,0 +1,138 @@
import { isEmpty } from "lodash";
import {
CV_API_CALL_IN_PROGRESS,
CV_SEARCH_SUCCESS,
CV_CLEANUP,
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
CV_WEEKLY_PULLLIST_FETCHED,
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
LIBRARY_STATISTICS_FETCHED,
} from "../constants/action-types";
const initialState = {
pullList: [],
libraryStatistics: [],
searchResults: [],
searchQuery: {},
inProgress: false,
comicBookDetail: {},
comicBooksDetails: [],
issuesForVolume: [],
IMS_inProgress: false,
};
function comicinfoReducer(state = initialState, action) {
switch (action.type) {
case CV_API_CALL_IN_PROGRESS:
return {
...state,
inProgress: true,
};
case CV_SEARCH_SUCCESS:
return {
...state,
searchResults: action.searchResults,
searchQuery: action.searchQueryObject,
inProgress: false,
};
case IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS:
return {
...state,
IMS_inProgress: true,
};
case IMS_COMIC_BOOK_DB_OBJECT_FETCHED:
return {
...state,
comicBookDetail: action.comicBookDetail,
IMS_inProgress: false,
};
case IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED:
return {
...state,
comicBooksDetails: action.comicBooks,
IMS_inProgress: false,
};
case CV_CLEANUP:
return {
...state,
searchResults: [],
searchQuery: {},
issuesForVolume: [],
};
case CV_ISSUES_METADATA_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS:
return {
...state,
issuesForVolume: action.issues,
inProgress: false,
};
case CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED:
const updatedState = [...state.issuesForVolume];
action.matches.map((match) => {
updatedState.map((issue, idx) => {
const matches = [];
if (!isEmpty(match.hits.hits)) {
return match.hits.hits.map((hit) => {
if (
parseInt(issue.issue_number, 10) ===
hit._source.inferredMetadata.issue.number
) {
matches.push(hit);
const updatedIssueResult = { ...issue, matches };
updatedState[idx] = updatedIssueResult;
}
});
}
});
});
return {
...state,
issuesForVolume: updatedState,
};
case CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case CV_WEEKLY_PULLLIST_FETCHED: {
const foo = [];
action.data.map((item) => {
foo.push({issue: item})
});
return {
...state,
inProgress: false,
pullList: foo,
};
}
case LIBRARY_STATISTICS_CALL_IN_PROGRESS:
return {
inProgress: true,
...state,
};
case LIBRARY_STATISTICS_FETCHED:
return {
...state,
inProgress: false,
libraryStatistics: action.data,
};
default:
return state;
}
}
export default comicinfoReducer;

View File

@@ -1,18 +1,56 @@
import { isUndefined, map } from "lodash";
import { LOCATION_CHANGE } from "redux-first-history";
import { determineCoverFile } from "../shared/utils/metadata.utils";
import {
IMS_SOCKET_CONNECTION_CONNECTED,
IMS_SOCKET_CONNECTION_DISCONNECTED,
IMS_COMICBOOK_METADATA_FETCHED,
IMS_SOCKET_ERROR,
IMS_RAW_IMPORT_SUCCESSFUL,
IMS_RAW_IMPORT_FAILED,
IMS_RECENT_COMICS_FETCHED,
IMS_DATA_FETCH_ERROR,
IMS_WANTED_COMICS_FETCHED,
WANTED_COMICS_FETCHED,
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
IMS_CV_METADATA_IMPORT_FAILED,
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
LS_IMPORT,
LS_COVER_EXTRACTED,
LS_QUEUE_DRAINED,
LS_COMIC_ADDED,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
VOLUMES_FETCHED,
} from "../constants/action-types";
const initialState = {
dataTransferred: false,
IMSCallInProgress: false,
IMGCallInProgress: false,
SSCallInProgress: false,
imageAnalysisResults: {},
comicBookExtractionInProgress: false,
comicBookMetadata: [],
socketConnected: false,
comicVolumeGroups: [],
isSocketConnected: false,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportError: {},
rawImportError: {},
extractedComicBookArchive: [],
recentComics: [],
wantedComics: [],
libraryComics: [],
volumes: [],
librarySearchResultsFormatted: [],
librarySearchResultCount: 0,
libraryQueueResults: [],
librarySearchError: {},
};
function fileOpsReducer(state = initialState, action) {
@@ -21,14 +59,15 @@ function fileOpsReducer(state = initialState, action) {
return {
...state,
comicBookMetadata: [...state.comicBookMetadata, action.data],
dataTransferred: true,
IMSCallInProgress: false,
};
case IMS_SOCKET_CONNECTION_CONNECTED:
case LS_IMPORT_CALL_IN_PROGRESS: {
return {
...state,
socketConnected: action.socketConnected,
IMSCallInProgress: true,
};
}
case IMS_RAW_IMPORT_SUCCESSFUL:
return {
...state,
@@ -44,6 +83,167 @@ function fileOpsReducer(state = initialState, action) {
...state,
recentComics: action.data,
};
case IMS_WANTED_COMICS_FETCHED:
return {
...state,
wantedComics: action.data,
};
case IMS_CV_METADATA_IMPORT_SUCCESSFUL:
return {
...state,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportDetails: action.importResult,
};
case IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS:
return {
...state,
isComicVineMetadataImportInProgress: true,
};
case IMS_CV_METADATA_IMPORT_FAILED:
return {
...state,
isComicVineMetadataImportInProgress: false,
comicVineMetadataImportError: action.importError,
};
case IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS: {
return {
...state,
IMSCallInProgress: true,
};
}
case IMS_COMIC_BOOK_GROUPS_FETCHED: {
return {
...state,
comicVolumeGroups: action.data,
IMSCallInProgress: false,
};
}
case IMS_COMIC_BOOK_GROUPS_CALL_FAILED: {
return {
...state,
IMSCallInProgress: false,
error: action.error,
};
}
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS: {
return {
...state,
comicBookExtractionInProgress: true,
};
}
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS: {
return {
...state,
extractedComicBookArchive: action.extractedComicBookArchive,
comicBookExtractionInProgress: false,
};
}
case LOCATION_CHANGE: {
return {
...state,
extractedComicBookArchive: [],
};
}
case LS_IMPORT: {
return {
...state,
};
}
case LS_COVER_EXTRACTED: {
console.log("BASH", action);
return {
...state,
librarySearchResultCount: state.librarySearchResultCount + 1,
};
}
case LS_QUEUE_DRAINED: {
console.log("drained", action);
return {
...state,
};
}
case LS_COMIC_ADDED: {
console.log("ADDED na anna", action);
return {
...state,
};
}
case IMG_ANALYSIS_CALL_IN_PROGRESS: {
return {
...state,
IMGCallInProgress: true,
};
}
case IMG_ANALYSIS_DATA_FETCH_SUCCESS: {
return {
...state,
imageAnalysisResults: action.result,
};
}
case SS_SEARCH_IN_PROGRESS: {
return {
...state,
SSCallInProgress: true,
};
}
case SS_SEARCH_RESULTS_FETCHED: {
return {
...state,
libraryComics: action.data,
SSCallInProgress: false,
};
}
case SS_SEARCH_RESULTS_FETCHED_SPECIAL: {
const foo = [];
if (!isUndefined(action.data.hits)) {
map(action.data.hits.hits, ({ _source }) => {
foo.push(_source);
});
}
return {
...state,
librarySearchResultsFormatted: foo,
SSCallInProgress: false,
};
}
case WANTED_COMICS_FETCHED: {
const foo = [];
if (!isUndefined(action.data.hits)) {
map(action.data.hits.hits, ({ _source }) => {
foo.push(_source);
});
}
return {
...state,
wantedComics: foo,
SSCallInProgress: false,
};
}
case VOLUMES_FETCHED:
return {
...state,
volumes: action.data,
SSCallInProgress: false,
};
case SS_SEARCH_FAILED: {
return {
...state,
librarySearchError: action.data,
SSCallInProgress: false,
};
}
case FILEOPS_STATE_RESET: {
return {
...state,
imageAnalysisResults: {},
};
}
default:
return state;
}

View File

@@ -1,11 +1,11 @@
import { combineReducers } from "redux";
import { connectRouter } from "connected-react-router";
import comicinfoReducer from "../reducers/comicinfo.reducer";
import fileOpsReducer from "../reducers/fileops.reducer";
import airdcppReducer from "../reducers/airdcpp.reducer";
import settingsReducer from "../reducers/settings.reducer";
export default (history) =>
combineReducers({
comicInfo: comicinfoReducer,
fileOps: fileOpsReducer,
router: connectRouter(history),
});
export const reducers = {
comicInfo: comicinfoReducer,
fileOps: fileOpsReducer,
airdcpp: airdcppReducer,
settings: settingsReducer,
};

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