Compare commits

...

18 Commits

Author SHA1 Message Date
c6f719e78b 💅🏼 Formatting tweaks to tabs 2024-03-30 21:28:48 -04:00
b4d1b678b1 💅🏼 Formatting improvements 2024-03-29 23:26:43 -04:00
aa3192bc1a 🧲 Added visual indicators of torrent progress 2024-03-29 19:35:57 -04:00
56ddfbd16e 🧲 Surfacing torrent progress in UI via scheduled job 2024-03-27 22:23:24 -05:00
173735da45 🧲 Added downloads panel 2024-03-24 17:30:51 -04:00
f4408cd493 🧲 Fixed the auto-population of search box 2024-03-16 18:34:41 -04:00
41a9428729 🧲 Added a torrent download sub-panel 2024-03-07 05:50:49 -06:00
e1da6ddcef 🪢 Wiring up to addTorrent endpoint 2024-02-27 22:15:32 -05:00
df4cecedf0 📝 Added captions to screenshots 2024-02-23 16:59:31 -05:00
3dee53f33f 📝 Updated README 2024-02-23 16:52:45 -05:00
ef05dee600 Removed pngs 2024-02-23 16:42:37 -05:00
2b6bce4731 Merge branch 'main' of https://github.com/rishighan/threetwo 2024-02-23 16:41:21 -05:00
fc4c5c61e2 🌇 Updated screenshots 2024-02-23 16:41:14 -05:00
ae04260f69 Updated .gitignore to ignore storybook static assets (#102)
*  Updated .gitignore to ignore storybook static assets

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

* 🏗️ Added stats to dashboard

* 🐯 Prowlarr integration WIP

* 🐯 Prowlarr settings form scaffold

* 🧲 Added a form for torrent search

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 09:07:19 -06:00
c03f706e9d Dark mode 2 (#100)
* 🗂️ Added tab icons and styles

* 🪑 Styled download panel table

* 🪑 Cleaned up the DC++ search results table

* 🪑 Many changes to DC++ downloads table

* 🏗️ Wired up search with RQ

* 🏗️ Changes to ComicDetail section

* 🔧 Fixing table styes

* 🏗 Fixed the archive ops panel

* 🔧 Tweaked Archive ops further

* 🃏 Styling the action menu

* 🧩 CV Match panel refactor WIP

* 🏗️ Refactored the action menu

* 🤼 Cleaning up CV match panel

* 🏗️ Refactored the scored matches

* 🤼 Revamped CV match panel UX

* 🖌️ Styling tweaks to the side panel

* 🖌️ Cleaned up the form

* 🏗️ Refactored the search form

* 🤐 Added a uncompress indicator

* 🏗️ Fix for encoding # in URIs

* 🔧 Fixed # symbol handling in URLs

* 🔧 Started work on Edit Metadata panel

* 🔎 Added a check for existing uncompressed archives

* 🏗️ Settings styling tweaks

* 🏗️ Fixed invalidation of archiveOps

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

* 🏗️ Fixed CV-sourced Volume info panel

* 🏗️ Fixed volume group card stacks on Dashboard

* 🔍 Fixing CV search page

* 🏗️ Refactoring Volume groups and wanted panel

* 🏗️ Cleaning up useless files

* 🛝 Added keen-slider for pull list

* 🏗️ Abstracted heading/subheading into Header

* 🏗️ Continued refactoring of PullList, Volumes etc.

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

* 🔧 Formatted the search query box

* 🔧 Implementing download method

* 🏗️ Refactored the AirDC++ download panel

* 🌜 Initial Dark Mode support

* 🌜 Trying dark mode on the react-select

* Update App.scss

* 🏗️ Migrating Navbar to TailwindCSS

* 🖼️ Added solar icons

* 🔧 Added solar icons

* 🔧 Added code for dark mode toggle

* 🏗️ Wiring up the dark mode toggle

* 🌜 Added Dark mode to the body

* 🏗️ Building out the import page

* 🪑 Cleaning up the table styles

* 🏗️ Cleaned up past imports table

* 🏗️ Refactored Import socket events

* 🏗️ Refactored the card grid on dashboard

* 🏗️ Building variants for Cards

* 🏗️ Added a horizontal medium variant

* 🏗️ Cleaning up forms and cards

* 🔧 Styling form inputs

* 🏗️ Form refactor

* 🔠 Added a monospace font

* 🪑 Refactoring the table

* 🧹 Formatting in connection confirmation panels

* 🏗️ Refactoring table for library

* 🏗️ Added icons and details to metadata

* 🏗️ Cleaned the table further

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

*  Removing yarn.lockfile
2023-12-21 16:23:29 -05:00
dependabot[bot]
68442894d0 Bump vite from 5.0.2 to 5.0.5 (#99)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.2 to 5.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.5/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-06 22:41:14 -06:00
78 changed files with 5755 additions and 4886 deletions

1
.gitignore vendored
View File

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

View File

@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots ### Screenshots
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png) #### Dashboard
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png) ![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png) #### Issue View
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png) ![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel ### 🦄 Early Development Support Channel
@@ -28,7 +39,8 @@ ThreeTwo! currently is set up as:
1. The UI, this repo. 1. The UI, this repo.
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service) 2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service) 3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency. 4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
## Docker Instructions ## Docker Instructions
@@ -43,20 +55,18 @@ For debugging and troubleshooting, you can run this app locally using these step
3. This will open `http://localhost:5173` in your default browser 3. This will open `http://localhost:5173` in your default browser
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work. 4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## Troubleshooting ## Troubleshooting
### Docker ### Docker
1. `docker-compose up` is taking a long time 1. `docker-compose up` is taking a long time
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading. This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
2. What folder do my comics go in? 2. What folder do my comics go in?
Your comics go in the `comics` directory at the root of this project. Your comics go in the `comics` directory at the root of this project.
## Contribution Guidelines ## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md) See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "threetwo", "name": "threetwo",
"version": "0.0.2", "version": "0.1.0",
"description": "ThreeTwo! A comic book curator.", "description": "ThreeTwo! A good comic book curator.",
"main": "server/index.js", "main": "server/index.js",
"typings": "server/index.js", "typings": "server/index.js",
"scripts": { "scripts": {
@@ -19,13 +19,13 @@
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/utilities": "^3.2.1",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@redux-devtools/extension": "^3.2.5", "@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5", "@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3", "@tanstack/react-table": "^8.9.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^4.2.1",
"airdcpp-apisocket": "^2.5.0-beta.2", "airdcpp-apisocket": "^2.5.0-beta.2",
"axios": "^1.3.4", "axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1", "axios-cache-interceptor": "^1.0.1",
@@ -38,10 +38,12 @@
"filename-parser": "^1.0.2", "filename-parser": "^1.0.2",
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"immer": "^10.0.3", "immer": "^10.0.3",
"jsdoc": "^3.6.10", "jsdoc": "^3.6.10",
"keen-slider": "^6.8.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@@ -49,33 +51,32 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-collapsible": "^2.9.0", "react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0", "react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.0", "react-day-picker": "^8.10.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4", "react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0", "react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-popper": "^2.3.0",
"react-router": "^6.9.0", "react-router": "^6.9.0",
"react-router-dom": "^6.9.0", "react-router-dom": "^6.9.0",
"react-select": "^5.8.0", "react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2", "react-select-async-paginate": "^0.7.2",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0", "react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1", "reapop": "^4.2.1",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2", "socket.io-client": "^4.3.2",
"styled-components": "^6.1.0", "styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.0", "vite": "^5.0.5",
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34", "websocket": "^1.0.34",
"zustand": "^4.4.6" "zustand": "^4.4.6"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/solar": "^1.1.8",
"@iconify/tailwind": "^0.1.4",
"@storybook/addon-essentials": "^7.4.1", "@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-interactions": "^7.4.1", "@storybook/addon-interactions": "^7.4.1",
"@storybook/addon-links": "^7.4.1", "@storybook/addon-links": "^7.4.1",
@@ -95,8 +96,8 @@
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/react-redux": "^7.1.25", "@types/react-redux": "^7.1.25",
"autoprefixer": "^10.4.16",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bulma": "^0.9.4",
"docdash": "^2.0.2", "docdash": "^2.0.2",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
@@ -111,12 +112,18 @@
"install": "^0.13.0", "install": "^0.13.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"postcss": "^8.4.32",
"postcss-import": "^15.1.0",
"prettier": "^2.2.1", "prettier": "^2.2.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"rimraf": "^4.1.3", "rimraf": "^4.1.3",
"sass": "^1.69.5", "sass": "^1.69.5",
"storybook": "^7.3.2", "storybook": "^7.3.2",
"tailwindcss": "^3.4.1",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6" "typescript": "^5.1.6"
},
"resolutions": {
"jackspeak": "2.1.1"
} }
} }

7
postcss.config.js Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
screenshots/CVMatching.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
screenshots/ComicDetail.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
screenshots/Dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
screenshots/Import.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

BIN
screenshots/Library.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

View File

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

View File

@@ -1,12 +1,12 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import Navbar from "./shared/Navbar"; import { Navbar2 } from "./shared/Navbar2";
import "../assets/scss/App.scss"; import "../assets/scss/App.scss";
export const App = (): ReactElement => { export const App = (): ReactElement => {
return ( return (
<> <>
<Navbar /> <Navbar2 />
<Outlet /> <Outlet />
</> </>
); );

View File

@@ -1,9 +1,5 @@
import React, { useCallback, ReactElement, useEffect, useState } from "react"; import React, { useCallback, ReactElement, useEffect, useState } from "react";
import { import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
downloadAirDCPPItem,
getBundlesForComic,
sleep,
} from "../../actions/airdcpp.actions";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings"; import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
@@ -12,7 +8,8 @@ import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
@@ -28,14 +25,22 @@ export const AcquisitionPanel = (
airDCPPSocketInstance, airDCPPSocketInstance,
airDCPPClientConfiguration, airDCPPClientConfiguration,
airDCPPSessionInformation, airDCPPSessionInformation,
airDCPPDownloadTick,
} = useStore( } = useStore(
useShallow((state) => ({ useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance, airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration, airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation, airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
})), })),
); );
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
/** /**
* Get the hubs list from an AirDCPP Socket * Get the hubs list from an AirDCPP Socket
*/ */
@@ -43,34 +48,16 @@ export const AcquisitionPanel = (
queryKey: ["hubs"], queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`), queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
}); });
const { comicObjectId } = props;
const issueName = props.query.issue.name || ""; const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Selectors for picking state
// 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);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
const [dcppQuery, setDcppQuery] = useState({}); const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]); const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
const queryClient = useQueryClient();
// Construct a AirDC++ query based on metadata inferred, upon component mount // Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that // Pre-populate the search input with the search string, so that
@@ -88,15 +75,18 @@ export const AcquisitionPanel = (
setDcppQuery(dcppSearchQuery); setDcppQuery(dcppSearchQuery);
}, []); }, []);
/**
* Method to perform a search via an AirDC++ websocket
* @param {SearchData} data - a SearchData query
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
*/
const search = async (data: SearchData, ADCPPSocket: any) => { const search = async (data: SearchData, ADCPPSocket: any) => {
try { try {
if (!ADCPPSocket.isConnected()) { if (!ADCPPSocket.isConnected()) {
await ADCPPSocket(); await ADCPPSocket();
} }
const instance: SearchInstance = await ADCPPSocket.post("search"); const instance: SearchInstance = await ADCPPSocket.post("search");
// dispatch({ setAirDCPPSearchStatus(true);
// type: AIRDCPP_SEARCH_IN_PROGRESS,
// });
// We want to get notified about every new result in order to make the user experience better // We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener( await ADCPPSocket.addListener(
@@ -142,6 +132,8 @@ export const AcquisitionPanel = (
const currentInstance = await ADCPPSocket.get( const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`, `search/${instance.id}`,
); );
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
if (currentInstance.result_count === 0) { if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user // ...nothing was received, show an informative message to the user
console.log("No more search results."); console.log("No more search results.");
@@ -149,11 +141,8 @@ export const AcquisitionPanel = (
// The search can now be considered to be "complete" // 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 // If there's an "in progress" indicator in the UI, that could also be disabled here
// dispatch({ setAirDCPPSearchInstance(instance);
// type: AIRDCPP_HUB_SEARCHES_SENT, setAirDCPPSearchStatus(false);
// searchInfo,
// instance,
// });
}, },
instance.id, instance.id,
); );
@@ -164,6 +153,70 @@ export const AcquisitionPanel = (
throw error; throw error;
} }
}; };
/**
* Method to download a bundle associated with a search result from AirDC++
* @param {Number} searchInstanceId - description
* @param {String} resultId - description
* @param {String} comicObjectId - description
* @param {String} name - description
* @param {Number} size - description
* @param {any} type - description
* @param {any} ADCPPSocket - description
* @returns {void} - description
*/
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
ADCPPSocket: any,
): void => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
);
if (!isNil(downloadResult)) {
bundleDBImportResult = await axios({
method: "POST",
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
},
});
console.log(bundleDBImportResult?.data);
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
// dispatch({
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
// downloadResult,
// bundleDBImportResult,
// });
//
// dispatch({
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
// comicBookDetail: bundleDBImportResult.data,
// IMS_inProgress: false,
// });
}
} catch (error) {
throw error;
}
};
const getDCPPSearchResults = async (searchQuery) => { const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = { const manualQuery = {
query: { query: {
@@ -177,42 +230,9 @@ export const AcquisitionPanel = (
search(manualQuery, airDCPPSocketInstance); search(manualQuery, airDCPPSocketInstance);
}; };
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
// dispatch(
// downloadAirDCPPItem(
// searchInstanceId,
// resultId,
// props.comicObjectId,
// name,
// size,
// type,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
// this is to update the download count badge on the downloads tab
// dispatch(
// getBundlesForComic(
// props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
},
[],
);
console.log("yaman", airDCPPSearchResults);
return ( return (
<> <>
<div className="comic-detail columns"> <div className="mt-5">
{!isEmpty(airDCPPSocketInstance) ? ( {!isEmpty(airDCPPSocketInstance) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
@@ -220,51 +240,47 @@ export const AcquisitionPanel = (
issueName, issueName,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form <form onSubmit={handleSubmit}>
onSubmit={handleSubmit} <Field name="issueName">
className="column is-three-quarters" {({ input, meta }) => {
> return (
<div className="box search"> <div className="max-w-fit">
<div className="columns"> <div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<Field name="issueName"> <div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
{({ input, meta }) => { <i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info"></span>
</div> </div>
); <input
}} {...input}
</Field> className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name"
/>
<div className="column"> <button
<button className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit" type="submit"
className={ >
false <div className="flex flex-row">
? "button is-loading is-warning" Search DC++
: "button is-success is-light" <div className="h-5 w-5 ml-2">
} <img
> src="/src/client/assets/img/airdcpp_logo.svg"
<span className="icon is-small"> className="h-5 w-5"
<img src="/src/client/assets/img/airdcpp_logo.svg" /> />
</span> </div>
<span className="airdcpp-text">Search on AirDC++</span> </div>
</button> </button>
</div> </div>
</div> </div>
</div> );
}}
</Field>
</form> </form>
)} )}
/> />
) : ( ) : (
<div className="column is-three-fifths"> <div className="">
<article className="message is-info"> <article className="">
<div className="message-body is-size-6 is-family-secondary"> <div className="">
AirDC++ is not configured. Please configure it in{" "} AirDC++ is not configured. Please configure it in{" "}
<code>Settings &gt; AirDC++ &gt; Connection</code>. <code>Settings &gt; AirDC++ &gt; Connection</code>.
</div> </div>
@@ -273,29 +289,86 @@ export const AcquisitionPanel = (
)} )}
</div> </div>
{/* AirDC++ search instance details */}
{!isNil(airDCPPSearchInstance) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
)}
{/* AirDC++ results */} {/* AirDC++ results */}
<div className="columns"> <div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( {!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="column"> <div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
<table className="table"> <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
<th>Type</th> Name
<th>Slots</th> </th>
<th>Actions</th> <th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Type
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Slots
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{map(airDCPPSearchResults, ({ result }, idx) => { {map(airDCPPSearchResults, ({ result }, idx) => {
return ( return (
<tr <tr
key={idx} key={idx}
className={ className={
!isNil(result.dupe) ? "dupe-search-result" : "" !isNil(result.dupe)
? "bg-gray-100 dark:bg-gray-700"
: "w-fit text-sm"
} }
> >
<td> <td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
<p className="mb-2"> <p className="mb-2">
{result.type.id === "directory" ? ( {result.type.id === "directory" ? (
<i className="fas fa-folder"></i> <i className="fas fa-folder"></i>
@@ -305,16 +378,39 @@ export const AcquisitionPanel = (
<dl> <dl>
<dd> <dd>
<div className="tags"> <div className="inline-flex flex-row gap-2">
{!isNil(result.dupe) ? ( {!isNil(result.dupe) ? (
<span className="tag is-warning">Dupe</span> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
Dupe
</span>
</span>
) : null} ) : null}
<span className="tag is-light is-info">
{result.users.user.nicks} {/* Nicks */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--user-rounded-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.users.user.nicks}
</span>
</span> </span>
{/* Flags */}
{result.users.user.flags.map((flag, idx) => ( {result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
{flag} <span className="pr-1 pt-1">
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{flag}
</span>
</span> </span>
))} ))}
</div> </div>
@@ -322,39 +418,48 @@ export const AcquisitionPanel = (
</dl> </dl>
</td> </td>
<td> <td>
<span className="tag is-light is-info"> {/* Extension */}
{result.type.id === "directory" <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
? "directory" <span className="pr-1 pt-1">
: result.type.str} <i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.type.str}
</span>
</span> </span>
</td> </td>
<td> <td className="px-2">
<div className="tags has-addons"> {/* Slots */}
<span className="tag is-success"> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
{result.slots.free} free <span className="pr-1 pt-1">
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
</span> </span>
<span className="tag is-light">
{result.slots.total} <span className="text-md text-slate-500 dark:text-slate-900">
{result.slots.total} slots; {result.slots.free} free
</span> </span>
</div> </span>
</td> </td>
<td> <td className="px-2">
<button <button
className="button is-small is-light is-success" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => onClick={() =>
downloadDCPPResult( download(
searchInstance.id, airDCPPSearchInstance.id,
result.id, result.id,
comicObjectId,
result.name, result.name,
result.size, result.size,
result.type, result.type,
airDCPPSocketInstance,
) )
} }
> >
<span className="icon"> <span className="text-xs">Download</span>
<i className="fas fa-file-download"></i> <span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span> </span>
<span>Download </span>
</button> </button>
</td> </td>
</tr> </tr>
@@ -364,25 +469,28 @@ export const AcquisitionPanel = (
</table> </table>
</div> </div>
) : ( ) : (
<div className="column is-three-fifths"> <div className="">
<article className="message is-info"> <article
<div className="message-body is-size-6 is-family-secondary"> role="alert"
<p> className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
The default search term is an auto-detected title; you may >
need to change it to get better matches if the auto-detected <div>
one doesn't work. The default search term is an auto-detected title; you may need
</p> to change it to get better matches if the auto-detected one
doesn't work.
</div> </div>
</article> </article>
<article className="message is-warning"> <article
<div className="message-body is-size-6 is-family-secondary"> role="alert"
<p className="content"> className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
Searching via <strong>AirDC++</strong> is still in >
<strong>alpha</strong>. Some searches may take arbitrarily <div>
long, or may not work at all. Searches from <code>ADCS</code> Searching via <strong>AirDC++</strong> is still in{" "}
hubs are more reliable than <code>NMDCS</code> ones. <strong>alpha</strong>. Some searches may take arbitrarily long,
</p> or may not work at all. Searches from{" "}
<code className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
</div> </div>
</article> </article>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import { isEmpty, isNil, isUndefined } from "lodash"; import React, { ReactElement } from "react";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail"; import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
@@ -14,7 +12,7 @@ export const ComicDetailContainer = (): ReactElement | null => {
isLoading, isLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: [], queryKey: ["comicBookMetadata"],
queryFn: async () => queryFn: async () =>
await axios({ await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`, url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
@@ -24,11 +22,6 @@ export const ComicDetailContainer = (): ReactElement | null => {
}, },
}), }),
}); });
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{ {
isError && <>Error</>; isError && <>Error</>;

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ import React, { ReactElement } from "react";
export const DownloadProgressTick = (props): ReactElement => { export const DownloadProgressTick = (props): ReactElement => {
return ( return (
<div > <div>
<h4 className="is-size-6">{props.data.name}</h4> <h4 className="is-size-5">{props.data.name}</h4>
<div> <div>
<span className="is-size-3 has-text-weight-semibold"> <span className="is-size-4 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "} {prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "} {prettyBytes(props.data.size)}{" "}
</span> </span>
@@ -20,13 +20,12 @@ export const DownloadProgressTick = (props): ReactElement => {
% %
</progress> </progress>
</div> </div>
<div className="is-size-5"> <div className="is-size-6 mt-1 mb-2">
{prettyBytes(props.data.speed)} per second. <p>{prettyBytes(props.data.speed)} per second.</p>
</div>
<div className="is-size-5">
Time left: Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)} {Math.round(parseInt(props.data.seconds_left) / 60)}
</div> </div>
<div>{props.data.target}</div> <div>{props.data.target}</div>
</div> </div>
); );

View File

@@ -1,103 +1,147 @@
import React, { useEffect, useContext, ReactElement } from "react"; import React, { useEffect, useContext, ReactElement, useState } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { RootState } from "threetwo-ui-typings"; import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash"; import { isEmpty, map } from "lodash";
import prettyBytes from "pretty-bytes"; import { AirDCPPBundles } from "./AirDCPPBundles";
import dayjs from "dayjs"; import { TorrentDownloads } from "./TorrentDownloads";
import ellipsize from "ellipsize"; import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
LIBRARY_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
TORRENT_JOB_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
interface IDownloadsPanelProps { interface IDownloadsPanelProps {
data: any; key: number;
comicObjectId: string;
} }
export const DownloadsPanel = ( export const DownloadsPanel = (
props: IDownloadsPanelProps, props: IDownloadsPanelProps,
): ReactElement | null => { ): ReactElement | null => {
// const bundles = useSelector((state: RootState) => { const { comicObjectId } = useParams<{ comicObjectId: string }>();
// return state.airdcpp.bundles; const [bundles, setBundles] = useState([]);
// }); const [infoHashes, setInfoHashes] = useState<string[]>([]);
// const [torrentDetails, setTorrentDetails] = useState([]);
// // AirDCPP Socket initialization const [activeTab, setActiveTab] = useState("torrents");
// const userSettings = useSelector((state: RootState) => state.settings.data); const { airDCPPSocketInstance, socketIOInstance } = useStore(
// const airDCPPConfiguration = useContext(AirDCPPSocketContext); useShallow((state: any) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
const { socketIOInstance: state.socketIOInstance,
airDCPPState: { socket, settings }, })),
} = airDCPPConfiguration; );
// React to torrent progress data sent over websockets
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
const torrents = data.torrents
.flatMap(({ _id, details }) => {
if (_id === comicObjectId) {
return details;
}
})
.filter((item) => item !== undefined);
setTorrentDetails(torrents);
});
// Fetch the downloaded files and currently-downloading file(s) from AirDC++ // Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => { const { data: comicObject, isSuccess } = useQuery({
try { queryKey: ["bundles"],
if (!isEmpty(userSettings)) { queryFn: async () =>
// dispatch( await axios({
// getBundlesForComic(props.comicObjectId, socket, { url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
// username: `${settings.directConnect.client.host.username}`, method: "POST",
// password: `${settings.directConnect.client.host.password}`, headers: {
// }), "Content-Type": "application/json; charset=utf-8",
// ); },
} data: {
} catch (error) { id: `${comicObjectId}`,
throw new Error(error); },
} }),
}, [airDCPPConfiguration]); });
const Bundles = (props) => { const getBundles = async (comicObject) => {
return !isEmpty(props.data) ? ( if (comicObject?.data.acquisition.directconnect) {
<div className="column is-full"> const filteredBundles =
<table className="table is-striped"> comicObject.data.acquisition.directconnect.downloads.map(
<thead> async ({ bundleId }) => {
<tr> return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
<th>Filename</th> },
<th>Size</th> );
<th>Download Time</th> return await Promise.all(filteredBundles);
<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) ? ( // Call the scheduled job for fetching torrent data
<> // triggered by the active tab been set to "torrents"
<div className="columns is-multiline"> const { data: torrentData } = useQuery({
{!isEmpty(socket) ? ( queryFn: () =>
<Bundles data={bundles} /> axios({
) : ( url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
<div className="column is-three-fifths"> method: "GET",
<article className="message is-info"> params: {
<div className="message-body is-size-6 is-family-secondary"> trigger: activeTab,
AirDC++ is not configured. Please configure it in{" "} },
<code>Settings</code>. }),
</div> queryKey: [activeTab],
</article> });
</div>
)}
</div>
</>
) : null;
};
useEffect(() => {
getBundles(comicObject).then((result) => {
setBundles(result);
});
}, [comicObject]);
return (
<div className="columns is-multiline">
{!isEmpty(airDCPPSocketInstance) &&
!isEmpty(bundles) &&
activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
<div>
<div className="sm:hidden">
<label htmlFor="Download Type" className="sr-only">
Download Type
</label>
<select id="Tab" className="w-full rounded-md border-gray-200">
<option>DC++ Downloads</option>
<option>Torrents</option>
</select>
</div>
<div className="hidden sm:block">
<nav className="flex gap-6" aria-label="Tabs">
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "directconnect"
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
aria-current="page"
onClick={() => setActiveTab("directconnect")}
>
DC++ Downloads
</a>
<a
href="#"
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
activeTab === "torrents"
? "bg-slate-200 text-slate-800"
: "dark:text-slate-400 text-slate-800"
}`}
onClick={() => setActiveTab("torrents")}
>
Torrents
</a>
</nav>
</div>
</div>
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
</div>
);
};
export default DownloadsPanel; export default DownloadsPanel;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,113 +1,99 @@
import React, { ReactElement, useEffect } from "react"; import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash"; import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
export const LibraryStatistics = ( export const LibraryStatistics = (
props: ILibraryStatisticsProps, props: ILibraryStatisticsProps,
): ReactElement => { ): ReactElement => {
// const { stats } = props; const { stats } = props;
return ( return (
<div className="mt-5"> <div className="mt-5">
<h4 className="title is-4"> <Header
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers headerContent="Your Library In Numbers"
</h4> subHeaderContent={
<p className="subtitle is-7">A brief snapshot of your library.</p> <span className="text-md">A brief snapshot of your library.</span>
<div className="columns is-multiline"> }
<div className="column is-narrow is-two-quarter"> iconClassNames="fa-solid fa-binoculars mr-2"
<dl className="box"> />
<dd className="is-size-4">
<span className="has-text-weight-bold"> <div className="mt-3">
{props.stats.totalDocuments} <div className="flex flex-row gap-5">
</span>{" "} <div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
files <dt className="text-lg font-medium text-gray-500">Library size</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</dd> </dd>
<dd className="is-size-4"> <dd>
Library size <span className="text-2xl text-green-600">
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize && {props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)} prettyBytes(props.stats.comicDirectorySize)}
</span> </span>
</dd> </dd>
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && ( !isEmpty(props.stats.statistics[0].issues) && (
<dd className="is-size-6"> <div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="has-text-weight-bold"> <span className="text-xl">
{props.stats.statistics[0].issues.length} {props.stats.statistics[0].issues.length}
</span>{" "} </span>{" "}
tagged with ComicVine tagged with ComicVine
</dd> </div>
)} )}
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && ( !isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<dd className="is-size-6"> <div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="has-text-weight-bold"> <span className="text-xl">
{props.stats.statistics[0].issuesWithComicInfoXML.length} {props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "} </span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1"> <span className="tag is-warning has-text-weight-bold mr-2 ml-1">
ComicInfo.xml with ComicInfo.xml
</span> </span>
</dd> </div>
)} )}
</dl> </div>
</div>
<div className="p-3 column is-one-quarter"> <div className="">
<dl className="box"> {!isUndefined(props.stats.statistics) &&
<dd className="is-size-6"> !isEmpty(props.stats.statistics[0].fileTypes) &&
<span className="has-text-weight-bold"></span> Issues map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
</dd> return (
<dd className="is-size-6"> <span
<span className="has-text-weight-bold">304</span> Volumes key={idx}
</dd> className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
<dd className="is-size-6"> >
{!isUndefined(props.stats.statistics) && {fileType.data.length} {fileType._id}
!isEmpty(props.stats.statistics[0].fileTypes) && </span>
map(props.stats.statistics[0].fileTypes, (fileType, idx) => { );
return ( })}
<span key={idx}> </div>
<span className="has-text-weight-bold">
{fileType.data.length}
</span>
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
{fileType._id}
</span>
</span>
);
})}
</dd>
</dl>
</div>
{/* file types */} {/* file types */}
<div className="p-3 column is-two-fifths"> <div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
{/* publisher with most issues */} {/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) && {!isUndefined(props.stats.statistics) &&
!isEmpty( !isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0], props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && ( ) && (
<dd className="is-size-6"> <>
<span className="has-text-weight-bold"> <span className="">
{ {
props.stats.statistics[0] props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id .publisherWithMostComicsInLibrary[0]._id
} }
</span> </span>
{" has the most issues "} {" has the most issues "}
<span className="has-text-weight-bold"> <span className="">
{ {
props.stats.statistics[0] props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0].count .publisherWithMostComicsInLibrary[0].count
} }
</span> </span>
</dd> </>
)} )}
<dd className="is-size-6"> </div>
<span className="has-text-weight-bold">304</span> Volumes
</dd>
</dl>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
type WantedComicsListProps = { type WantedComicsListProps = {
comics: any; comics: any;
@@ -14,37 +14,17 @@ type WantedComicsListProps = {
export const WantedComicsList = ({ export const WantedComicsList = ({
comics, comics,
}: WantedComicsListProps): ReactElement => { }: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return ( return (
<> <>
<div className="content mt-6"> <Header
<a className="mb-1" onClick={navigateToWantedComics}> headerContent="Wanted Comics"
<span className="is-size-4 has-text-weight-semibold"> subHeaderContent="Comics marked as wanted from various sources"
<i className="fa-solid fa-asterisk"></i> Wanted Comics iconClassNames="fa-solid fa-binoculars mr-2"
</span> link={"/wanted"}
<span className="icon mt-1"> />
<i className="fa-solid fa-angle-right"></i> <div className="grid grid-cols-5 gap-6 mt-3">
</span>
</a>
<p className="subtitle is-7">
Comics marked as wanted from various sources.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map( {map(
comics, comics,
({ ({
@@ -73,42 +53,54 @@ export const WantedComicsList = ({
return ( return (
<Card <Card
key={_id} key={_id}
orientation={"vertical"} orientation={"vertical-2"}
imageUrl={url} imageUrl={url}
hasDetails hasDetails
title={issueName ? titleElement : <span>No Name</span>} title={issueName ? titleElement : <span>No Name</span>}
> >
<div className="content is-flex is-flex-direction-row"> <div className="pb-1">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/locglogo.svg" />
</span>
)}
{/* Issue type */} {/* Issue type */}
{isComicBookMetadataAvailable && {isComicBookMetadataAvailable &&
!isNil( !isNil(
detectIssueTypes(comicvine.volumeInformation.description), detectIssueTypes(comicvine.volumeInformation.description),
) ? ( ) ? (
<span className="tag is-warning"> <div className="my-2">
{ <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
detectIssueTypes( <span className="pr-1 pt-1">
comicvine.volumeInformation.description, <i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
).displayName </span>
}
</span> <span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
</span>
</div>
) : null} ) : null}
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-7 h-7"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div> </div>
</Card> </Card>
); );
}, },
)} )}
</Masonry> </div>
</> </>
); );
}; };

View File

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

View File

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

View File

@@ -53,25 +53,6 @@ export const Import = (props: IProps): ReactElement => {
}), }),
}); });
// 1a. Act on each comic issue successfully imported/failed, as indicated
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
const { completedJobCount, importResult } = data;
importJobQueue.setJobCount("successful", completedJobCount);
importJobQueue.setMostRecentImport(importResult.rawFileDetails.name);
});
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
const { failedJobCount } = data;
importJobQueue.setJobCount("failed", failedJobCount);
});
// 1b. Clear the localStorage sessionId upon receiving the
// LS_IMPORT_QUEUE_DRAINED event
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
localStorage.removeItem("sessionId");
importJobQueue.setStatus("drained");
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
const toggleQueue = (queueAction: string, queueStatus: string) => { const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit( socketIOInstance.emit(
"call", "call",
@@ -94,29 +75,35 @@ export const Import = (props: IProps): ReactElement => {
switch (status) { switch (status) {
case "running": case "running":
return ( return (
<div className="control"> <div>
<button <button
className="button is-warning is-light" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => { onClick={() => {
toggleQueue("pause", "paused"); toggleQueue("pause", "paused");
importJobQueue.setStatus("paused"); importJobQueue.setStatus("paused");
}} }}
> >
<i className="fa-solid fa-pause mr-2"></i> Pause <span className="text-md">Pause</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
case "paused": case "paused":
return ( return (
<div className="control"> <div>
<button <button
className="button is-success is-light" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => { onClick={() => {
toggleQueue("resume", "running"); toggleQueue("resume", "running");
importJobQueue.setStatus("running"); importJobQueue.setStatus("running");
}} }}
> >
<i className="fa-solid fa-play mr-2"></i> Resume <span className="text-md">Resume</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
@@ -129,145 +116,180 @@ export const Import = (props: IProps): ReactElement => {
} }
}; };
return ( return (
<div className="container"> <div>
<section className="section is-small"> <section>
<h1 className="title">Import Comics</h1> <header className="bg-slate-200 dark:bg-slate-500">
<article className="message is-dark"> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="message-body"> <div className="sm:flex sm:items-center sm:justify-between">
<p className="mb-2"> <div className="text-center sm:text-left">
<span className="tag is-medium is-info is-light"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Import Comics Import
</span> </h1>
will add comics identified from the mapped folder into ThreeTwo's
database. <p className="mt-1.5 text-sm text-gray-500 dark:text-white">
</p> Import comics into the ThreeTwo library.
<p> </p>
Metadata from ComicInfo.xml, if present, will also be extracted. </div>
</p> </div>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div> </div>
</article> </header>
<p className="buttons">
<button <div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
className={ <article
importJobQueue.status === "drained" || role="alert"
importJobQueue.status === undefined className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
? "button is-medium"
: "button is-loading is-medium"
}
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
> >
<span className="icon"> <div>
<i className="fas fa-file-import"></i> <p>
</span> Importing will add comics identified from the mapped folder into
<span>Start Import</span> ThreeTwo's database.
</button> </p>
</p> <p>
Metadata from ComicInfo.xml, if present, will also be extracted.
</p>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div>
</article>
{importJobQueue.status !== "drained" && <div className="my-4">
!isUndefined(importJobQueue.status) && ( {importJobQueue.status === "drained" ||
(importJobQueue.status === undefined && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Start Import</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
))}
</div>
{/* Activity */}
{(importJobQueue.status === "running" ||
importJobQueue.status === "paused") && (
<> <>
<table className="table"> <span className="flex items-center my-5 max-w-screen-lg">
<thead> <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
<tr> Import Activity
<th>Completed Jobs</th> </span>
<th>Failed Jobs</th> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<th>Queue Controls</th>
<th>Queue Status</th>
</tr>
</thead>
<tbody>
<tr>
<th>
{importJobQueue.successfulJobCount > 0 && (
<div className="box has-background-success-light has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.successfulJobCount}
</span>
</div>
)}
</th>
<td>
{importJobQueue.failedJobCount > 0 && (
<div className="box has-background-danger has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.failedJobCount}
</span>
</div>
)}
</td>
<td>{renderQueueControls(importJobQueue.status)}</td>
<td>
{importJobQueue.status !== undefined ? (
<span className="tag is-warning">
{importJobQueue.status}
</span>
) : null}
</td>
</tr>
</tbody>
</table>
Imported{" "}
<span className="has-text-weight-bold">
{importJobQueue.mostRecentImport}
</span> </span>
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
{/* Successful import counts */}
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dd className="text-3xl text-green-600 md:text-5xl">
{importJobQueue.successfulJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
imported
</dt>
</div>
{/* Failed job counts */}
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
<dd className="text-3xl text-red-600 md:text-5xl">
{importJobQueue.failedJobCount}
</dd>
<dt className="text-lg font-medium text-gray-500">
failed
</dt>
</div>
<div className="flex flex-col dark:text-slate-200 text-slate-400">
<dd>{renderQueueControls(importJobQueue.status)}</dd>
</div>
</dl>
</div>
<div className="flex">
<span className="mt-2 dark:text-slate-200 text-slate-400">
Imported: <span>{importJobQueue.mostRecentImport}</span>
</span>
</div>
</> </>
)} )}
{/* Past imports */} {/* Past imports */}
{!isLoading && !isEmpty(data?.data) && ( {!isLoading && !isEmpty(data?.data) && (
<> <div className="max-w-screen-lg">
<h3 className="subtitle is-4 mt-5">Past Imports</h3> <span className="flex items-center mt-6">
<table className="table"> <span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
<thead> Past Imports
<tr> </span>
<th>Time Started</th> <span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
<th>Session Id</th> </span>
<th>Imported</th>
<th>Failed</th>
</tr>
</thead>
<tbody> <div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
{data?.data.map((jobResult, id) => { <table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
return ( <thead className="ltr:text-left rtl:text-right">
<tr key={id}> <tr>
<td> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
{format( Time Started
new Date(jobResult.earliestTimestamp), </th>
"EEEE, hh:mma, do LLLL Y", <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
)} Session Id
</td> </th>
<td> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
<span className="tag is-warning"> Imported
{jobResult.sessionId} </th>
</span> <th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
</td> Failed
<td> </th>
<span className="tag is-success">
{jobResult.completedJobs}
</span>
</td>
<td>
<span className="tag is-danger">
{jobResult.failedJobs}
</span>
</td>
</tr> </tr>
); </thead>
})}
</tbody> <tbody className="divide-y divide-gray-200">
</table> {data?.data.map((jobResult, id) => {
</> return (
)} <tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.completedJobs}
</p>
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
<span className="h-5 w-6">
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.failedJobs}
</p>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</section> </section>
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
import React, { useCallback, ReactElement } from "react"; import React, { useCallback, ReactElement, useState } from "react";
import { isNil, isEmpty } from "lodash"; import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings"; 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 { Form, Field } from "react-final-form";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {} interface ISearchProps {}
@@ -17,164 +20,223 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const dispatch = useDispatch(); const queryClient = useQueryClient();
const getCVSearchResults = useCallback( const [searchQuery, setSearchQuery] = useState("");
(searchQuery) => { const [comicVineMetadata, setComicVineMetadata] = useState({});
dispatch( const getCVSearchResults = (searchQuery) => {
comicinfoAPICall({ setSearchQuery(searchQuery.search);
callURIAction: "search", };
callMethod: "GET",
callParams: { const {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69", data: comicVineSearchResults,
query: searchQuery.search, isLoading,
format: "json", isSuccess,
limit: "10", } = useQuery({
offset: "0", queryFn: async () =>
field_list: await axios({
"id,name,deck,api_detail_url,image,description,volume,cover_date", url: `${COMICVINE_SERVICE_URI}/search`,
resources: "issue", method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
}),
queryKey: ["comicvineSearchResults", searchQuery],
enabled: !isNil(searchQuery),
});
// add to library
const { data: additionResult } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata:
{ comicvine: comicVineMetadata?.comicData } || null,
acquisition: { source: { wanted: true, name: "comicvine" } },
}, },
}), },
); }),
}, queryKey: ["additionResult"],
[dispatch], enabled: !isNil(comicVineMetadata.comicData),
); });
const addToLibrary = useCallback( const addToLibrary = (sourceName: string, comicData) =>
(sourceName: string, comicData) => setComicVineMetadata({ sourceName, comicData });
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => { const createDescriptionMarkup = (html) => {
return { __html: html }; return { __html: html };
}; };
return ( return (
<> <div>
<section className="container"> <section>
<div className="section search"> <header className="bg-slate-200 dark:bg-slate-500">
<h1 className="title">Search</h1> <div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form <Form
onSubmit={getCVSearchResults} onSubmit={getCVSearchResults}
initialValues={{ initialValues={{
...formData, ...formData,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit} className="form columns search"> <form onSubmit={handleSubmit}>
<div className="column is-three-quarters search"> <div className="flex flex-row w-full">
<Field name="search"> <div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
{({ input, meta }) => { <div className="w-10 text-gray-400">
return ( <i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
<input </div>
{...input}
className="input main-search-bar is-large" <Field name="search">
placeholder="Type an issue/volume name" {({ input, meta }) => {
/> return (
); <input
}} {...input}
</Field> className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
</div> placeholder="Type an issue/volume name"
<div className="column"> />
<button type="submit" className="button is-medium"> );
}}
</Field>
</div>
<button
className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
Search Search
</button> </button>
</div> </div>
</form> </form>
)} )}
/> />
{!isNil(comicVineSearchResults.results) && </div>
!isEmpty(comicVineSearchResults.results) ? ( {isLoading && <>Loading kaka...</>}
<div className="columns is-multiline"> {!isNil(comicVineSearchResults?.data.results) &&
{comicVineSearchResults.results.map((result) => { !isEmpty(comicVineSearchResults?.data.results) ? (
return ( <div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<div {comicVineSearchResults.data.results.map((result) => {
key={result.id} return isSuccess ? (
className="comicvine-metadata column is-10 mb-3" <div key={result.id} className="mb-5">
> <div className="flex flex-row">
<div className="columns"> <div className="mr-5">
<div className="column is-one-quarter"> <Card
<Card key={result.id}
key={result.id} orientation={"cover-only"}
orientation={"vertical"} imageUrl={result.image.small_url}
imageUrl={result.image.small_url} hasDetails={false}
title={result.name} />
hasDetails={false} </div>
></Card> <div className="column">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div> </div>
<div className="column"> <div className="field is-grouped mt-1">
<div className="is-size-3"> <div className="control">
{!isEmpty(result.name) ? ( <div className="tags has-addons">
result.name <span className="tag is-light">Cover date</span>
) : ( <span className="tag is-info is-light">
<span className="is-size-3">No Name</span> {dayjs(result.cover_date).format("MMM D, YYYY")}
)} </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>
</div> </div>
<a href={result.api_detail_url}> <div className="control">
{result.api_detail_url} <div className="tags has-addons">
</a> <span className="tag is-warning">{result.id}</span>
<p> </div>
{ellipsize( </div>
convert(result.description, { </div>
baseElements: {
selectors: ["p"], <a href={result.api_detail_url}>
}, {result.api_detail_url}
}), </a>
320, <p>
)} {ellipsize(
</p> convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<button <button
className="button is-success is-light is-outlined mt-2" className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => addToLibrary("comicvine", result)} onClick={() => addToLibrary("comicvine", result)}
> >
<i className="fa-solid fa-plus mr-2"></i> Want <i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
Mark as Wanted
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); </div>
})} ) : (
</div> <div>Loading</div>
) : ( );
<article className="message is-dark is-half"> })}
<div className="message-body"> </div>
<p className="mb-2"> ) : (
<span className="tag is-medium is-info is-light"> <div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Search the ComicVine database <article
</span> role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
Search and add issues, series and trade paperbacks to your Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or library. Then, download them using the configured AirDC++ or
torrent clients. torrent clients.
</p> </p>
</div> </div>
</article> </article>
)} </div>
</div> )}
</section> </section>
</> </div>
); );
}; };

View File

@@ -35,19 +35,16 @@ export const AirDCPPHubsForm = (): ReactElement => {
* Get the hubs list from an AirDCPP Socket * Get the hubs list from an AirDCPP Socket
*/ */
const { data: hubs } = useQuery({ const { data: hubs } = useQuery({
queryKey: [], queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`), queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
enabled: !!settings,
}); });
let hubList = {}; let hubList = {};
if (!isEmpty(hubs)) { if (!isNil(hubs)) {
console.log("hs", hubs);
hubList = hubs.map(({ hub_url, identity }) => ({ hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url, value: hub_url,
label: identity.name, label: identity.name,
})); }));
} }
console.log(hubList);
const { mutate } = useMutation({ const { mutate } = useMutation({
mutationFn: async (values) => mutationFn: async (values) =>
await axios({ await axios({

View File

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

View File

@@ -71,7 +71,6 @@ export const AirDCPPSettingsForm = (): ReactElement => {
const initFormData = !isUndefined(airDCPPClientConfiguration) const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration ? airDCPPClientConfiguration
: {}; : {};
console.log(airDCPPClientConfiguration);
return ( return (
<> <>
<ConnectionForm <ConnectionForm

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,37 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react"; import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar"; import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const WantedComics = (props): ReactElement => { export const WantedComics = (props): ReactElement => {
// const wantedComics = useSelector( const {
// (state: RootState) => state.fileOps.wantedComics, data: wantedComics,
// ); isSuccess,
useEffect(() => { isFetched,
// dispatch( isError,
// searchIssue( isLoading,
// { } = useQuery({
// query: {}, queryFn: async () =>
// }, await axios({
// { url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
// pagination: { method: "POST",
// size: 25, data: {
// from: 0, query: {},
// },
// type: "wanted",
// trigger: "wantedComicsPage"
// },
// ),
// );
}, []);
pagination: {
size: 25,
from: 0,
},
type: "wanted",
trigger: "wantedComicsPage",
},
}),
queryKey: ["wantedComics"],
enabled: true,
});
const columnData = [ const columnData = [
{ {
header: "Comic Information", header: "Comic Information",
@@ -36,7 +41,11 @@ export const WantedComics = (props): ReactElement => {
id: "comicDetails", id: "comicDetails",
minWidth: 350, minWidth: 350,
accessorFn: (data) => data, accessorFn: (data) => data,
cell: (value) => <MetadataPanel data={value.getValue()} />, cell: (value) => {
console.log("ASDASd", value);
const row = value.getValue()._source;
return row && <MetadataPanel data={row} />;
},
}, },
], ],
}, },
@@ -45,8 +54,8 @@ export const WantedComics = (props): ReactElement => {
columns: [ columns: [
{ {
header: "Files", header: "Files",
accessorKey: "acquisition",
align: "right", align: "right",
accessorKey: "_source.acquisition",
cell: (props) => { cell: (props) => {
const { const {
directconnect: { downloads }, directconnect: { downloads },
@@ -69,7 +78,7 @@ export const WantedComics = (props): ReactElement => {
{ {
header: "Download Details", header: "Download Details",
id: "downloadDetails", id: "downloadDetails",
accessorKey: "acquisition", accessorKey: "_source.acquisition",
cell: (data) => ( cell: (data) => (
<ol> <ol>
{data.getValue().directconnect.downloads.map((download, idx) => { {data.getValue().directconnect.downloads.map((download, idx) => {
@@ -98,23 +107,23 @@ export const WantedComics = (props): ReactElement => {
* @returns void * @returns void
* *
**/ **/
const nextPage = useCallback((pageIndex: number, pageSize: number) => { // const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch( // dispatch(
searchIssue( // searchIssue(
{ // {
query: {}, // query: {},
}, // },
{ // {
pagination: { // pagination: {
size: pageSize, // size: pageSize,
from: pageSize * pageIndex + 1, // from: pageSize * pageIndex + 1,
}, // },
type: "wanted", // type: "wanted",
trigger: "wantedComicsPage", // trigger: "wantedComicsPage",
}, // },
), // ),
); // );
}, []); // }, []);
/** /**
* Pagination control that fetches the previous x (pageSize) items * Pagination control that fetches the previous x (pageSize) items
@@ -123,55 +132,71 @@ export const WantedComics = (props): ReactElement => {
* @param {number} pageSize * @param {number} pageSize
* @returns void * @returns void
**/ **/
const previousPage = useCallback((pageIndex: number, pageSize: number) => { // const previousPage = useCallback((pageIndex: number, pageSize: number) => {
let from = 0; // let from = 0;
if (pageIndex === 2) { // if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17; // from = (pageIndex - 1) * pageSize + 2 - 17;
} else { // } else {
from = (pageIndex - 1) * pageSize + 2 - 16; // from = (pageIndex - 1) * pageSize + 2 - 16;
} // }
dispatch( // dispatch(
searchIssue( // searchIssue(
{ // {
query: {}, // query: {},
}, // },
{ // {
pagination: { // pagination: {
size: pageSize, // size: pageSize,
from, // from,
}, // },
type: "wanted", // type: "wanted",
trigger: "wantedComicsPage", // trigger: "wantedComicsPage",
}, // },
), // ),
); // );
}, []); // }, []);
return ( return (
<section className="container"> <div className="">
<div className="section"> <section className="">
<div className="header-area"> <header className="bg-slate-200 dark:bg-slate-500">
<h1 className="title">Wanted Comics</h1> <div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
</div> <div className="sm:flex sm:items-center sm:justify-between">
{!isEmpty(wantedComics) && ( <div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Wanted Comics
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse through comics you marked as "wanted."
</p>
</div>
</div>
</div>
</header>
{isSuccess && wantedComics?.data.hits?.hits ? (
<div> <div>
<div className="library"> <div className="library">
<T2Table <T2Table
sourceData={wantedComics} sourceData={wantedComics?.data.hits.hits}
totalPages={wantedComics.length} totalPages={wantedComics?.data.hits.hits.length}
columns={columnData} columns={columnData}
paginationHandlers={{ paginationHandlers={{
nextPage: nextPage, nextPage: () => {},
previousPage: previousPage, previousPage: () => {},
}} }}
// rowClickHandler={navigateToComicDetail} // rowClickHandler={navigateToComicDetail}
/> />
{/* pagination controls */} {/* pagination controls */}
</div> </div>
</div> </div>
)} ) : null}
</div> {isLoading ? <div>Loading...</div> : null}
</section> {isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</section>
</div>
); );
}; };

View File

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

View File

@@ -4,8 +4,8 @@ import { isEmpty, isNil } from "lodash";
interface ICardProps { interface ICardProps {
orientation: string; orientation: string;
imageUrl: string; imageUrl?: string;
hasDetails: boolean; hasDetails?: boolean;
title?: PropTypes.ReactElementLike | null; title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike; children?: PropTypes.ReactNodeLike;
borderColorClass?: string; borderColorClass?: string;
@@ -80,6 +80,87 @@ const renderCard = (props: ICardProps): ReactElement => {
</div> </div>
</div> </div>
); );
case "vertical-2":
return (
<div className="block rounded-md w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);
case "horizontal-small":
return (
<>
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} className="object-cover h-20 w-20" />
</div>
{/* details */}
<div className="w-fit h-fit pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
</div>
</div>
</>
);
case "horizontal-medium":
return (
<>
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} />
</div>
{/* details */}
<div className="pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
{props.hasDetails && <>{props.children}</>}
</div>
</div>
</>
);
case "cover-only":
return (
<>
{/* thumbnail */}
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
</>
);
case "card-with-info-panel":
return (
<>
<div className="flex flex-row">
{/* thumbnail */}
<div className="rounded-md overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
{/* myata-dyata */}
</div>
</>
);
default: default:
return <></>; return <></>;
} }

View File

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

View File

@@ -0,0 +1,127 @@
import React, { ChangeEventHandler, useRef, useState } from "react";
import { format, isValid, parse, parseISO } from "date-fns";
import FocusTrap from "focus-trap-react";
import { DayPicker, SelectSingleEventHandler } from "react-day-picker";
import { usePopper } from "react-popper";
export const DatePickerDialog = (props) => {
const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const popperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const customStyles = {
container: {
// Style for the entire container
border: "1px solid #ccc",
borderRadius: "4px",
padding: "10px",
width: "300px",
},
day: {
// Style for individual days
padding: "5px",
margin: "2px",
},
selected: {
// Style for selected days
backgroundColor: "#007bff",
color: "#fff",
},
disabled: {
// Style for disabled days
color: "#ccc",
},
today: {
// Style for today's date
backgroundColor: "#f0f0f0",
},
dayWrapper: {
// Style for the wrapper around each day
display: "inline-block",
},
};
const popper = usePopper(popperRef.current, popperElement, {
placement: "bottom-start",
});
const closePopper = () => {
setIsPopperOpen(false);
buttonRef?.current?.focus();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
};
const handleDaySelect: SelectSingleEventHandler = (date) => {
setSelected(date);
if (date) {
setter(format(date, "M-dd-yyyy"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={popperRef}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
onClick={handleButtonClick}
className="flex space-x-1 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
>
<span className="pr-1 pt-0.5 h-8">
<span className="icon-[solar--calendar-date-bold-duotone] w-6 h-6"></span>
</span>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
tabIndex={-1}
style={popper.styles.popper}
className="bg-slate-200 mt-3 p-2 rounded-lg z-50"
{...popper.attributes.popper}
ref={setPopperElement}
role="dialog"
aria-label="DayPicker calendar"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
styles={customStyles}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

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

View File

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

View File

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

View File

@@ -31,61 +31,62 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "rawFileDetails", name: "rawFileDetails",
content: () => ( content: () => (
<dl> <dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg">
<dt> <dt>
<h6 <p className="text-lg">{issueName}</p>
className="name has-text-weight-medium mb-1"
style={props.titleStyle}
>
{issueName}
</h6>
</dt> </dt>
<dd className="is-size-7"> <dd className="text-sm">
Is a part of{" "} is a part of{" "}
<span className="has-text-weight-semibold"> <span className="underline">
{inferredMetadata.issue.name} {inferredMetadata.issue.name}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span> </span>
</dd> </dd>
<dd className="is-size-7 mt-2"> {/* Issue number */}
<div className="field is-grouped is-grouped-multiline"> {inferredMetadata.issue.number && (
<div className="control"> <dd className="my-2">
<span className="tags"> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span <span className="pr-1 pt-1">
className="tag is-success is-light has-text-weight-semibold" <i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
style={props.tagsStyle}
>
{rawFileDetails.extension}
</span>
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.mimeType}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span> </span>
</div> <span className="text-md text-slate-900 dark:text-slate-900">
<div className="control"> {inferredMetadata.issue.number}
{inferredMetadata.issue.number && ( </span>
<div className="tags has-addons"> </span>
<span className="tag is-light" style={props.tagsStyle}> </dd>
Issue # )}
</span> <dd className="flex flex-row gap-2 w-max">
<span className="tag is-warning" style={props.tagsStyle}> {/* File extension */}
{inferredMetadata.issue.number} <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
</span> <span className="pr-1 pt-1">
</div> <i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
)} </span>
</div>
</div> <span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span>
)}
</dd> </dd>
</dl> </dl>
), ),
@@ -113,7 +114,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span> </span>
</span> </span>
</dd> </dd>
<dd className="is-size-7"> <dd className="is-size-7">
<span> <span>
{ellipsize( {ellipsize(
@@ -126,42 +126,13 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)} )}
</span> </span>
</dd> </dd>
<dd className="is-size-7 mt-2"> <dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline"> <span className="my-3 mx-2">
<div className="control"> {comicvine.volumeInformation.start_year}
<span className="tags"> </span>
<span {comicvine.volumeInformation.count_of_issues}
className="tag is-success is-light has-text-weight-semibold" ComicVine ID
style={props.tagsStyle} {comicvine.id}
>
{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> </dd>
</dl> </dl>
), ),
@@ -208,25 +179,14 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
}); });
return ( return (
<div className="column" style={props.containerStyle}> <div className="flex gap-5 my-3">
<div className="comic-detail issue-metadata"> <Card
<dl> imageUrl={url}
<dd> orientation={"cover-only"}
<div className="columns mt-2"> hasDetails={false}
<div className="column is-3"> imageStyle={props.imageStyle}
<Card />
imageUrl={url} <div>{metadataPanel.content()}</div>
orientation={"vertical"}
hasDetails={false}
imageStyle={props.imageStyle}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">{metadataPanel.content()}</div>
</div>
</dd>
</dl>
</div>
</div> </div>
); );
}; };

View File

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

View File

@@ -1,7 +1,5 @@
import React, { ReactElement, useMemo, useState } from "react"; import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -70,67 +68,45 @@ export const T2Table = (tableOptions): ReactElement => {
}); });
return ( return (
<> <div className="container max-w-fit mx-14">
<div className="columns table-controls"> <div>
{/* Search bar */} <div className="flex flex-row gap-2 justify-between mt-7">
<div className="column is-half"> {/* Search bar */}
<SearchBar /> {tableOptions.children}
</div> {/* pagination controls */}
{/* pagination controls */} <div>
<nav className="pagination columns"> Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
<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> <p>{totalPages} comics in all</p>
</div> {/* Prev/Next buttons */}
<div className="field has-addons"> <div className="inline-flex flex-row mt-4 mb-4">
<div className="control">
<button <button
className="button"
onClick={() => goToPreviousPage()} onClick={() => goToPreviousPage()}
disabled={pageIndex === 1} disabled={pageIndex === 1}
className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2"
> >
<i className="fas fa-chevron-left"></i> <i className="icon-[solar--arrow-left-linear] h-6 w-6"></i>
</button> </button>
</div>
<div className="control">
<button <button
className="button" className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2"
onClick={() => goToNextPage()} onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)} disabled={pageIndex > Math.floor(totalPages / pageSize)}
> >
<i className="fas fa-chevron-right"></i> <i className="icon-[solar--arrow-right-linear] h-6 w-6"></i>
</button> </button>
</div> </div>
<div className="field has-addons ml-5">
<p className="control">
<button className="button">
<span className="icon is-small">
<i className="fa-solid fa-list"></i>
</span>
</button>
</p>
<p className="control">
<button className="button">
<Link to="/library-grid">
<span className="icon is-small">
<i className="fa-solid fa-image"></i>
</span>
</Link>
</button>
</p>
</div>
</div> </div>
</nav> </div>
</div> </div>
<table className="table is-hoverable"> <table className="table-auto overflow-auto">
<thead> <thead className="sticky top-0 bg-slate-200 dark:bg-slate-500">
{table.getHeaderGroups().map((headerGroup, idx) => ( {table.getHeaderGroups().map((headerGroup, idx) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header, idx) => ( {headerGroup.headers.map((header, idx) => (
<th key={header.id} colSpan={header.colSpan}> <th
key={header.id}
colSpan={header.colSpan}
className="px-3 py-3"
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
@@ -147,17 +123,22 @@ export const T2Table = (tableOptions): ReactElement => {
{table.getRowModel().rows.map((row, idx) => { {table.getRowModel().rows.map((row, idx) => {
return ( return (
<tr key={row.id} onClick={() => rowClickHandler(row)}> <tr key={row.id} onClick={() => rowClickHandler(row)}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => {
<td key={cell.id}> return (
{flexRender(cell.column.columnDef.cell, cell.getContext())} <td key={cell.id} className="align-top">
</td> {flexRender(
))} cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
);
})}
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</> </div>
); );
}; };
@@ -170,5 +151,6 @@ T2Table.propTypes = {
previousPage: PropTypes.func, previousPage: PropTypes.func,
}), }),
rowClickHandler: PropTypes.func, rowClickHandler: PropTypes.func,
children: PropTypes.any,
}; };
export default T2Table; export default T2Table;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,13 @@ import { ErrorPage } from "./components/shared/ErrorPage";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
const root = createRoot(rootEl); const root = createRoot(rootEl);
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import"; import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard"; import Dashboard from "./components/Dashboard/Dashboard";
import Search from "./components/Search/Search";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer"; import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer"; import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
import Volumes from "./components/Volumes/Volumes";
import WantedComics from "./components/WantedComics/WantedComics";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -22,6 +24,7 @@ const router = createBrowserRouter([
element: <App />, element: <App />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ path: "/", element: <Dashboard /> },
{ path: "dashboard", element: <Dashboard /> }, { path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> }, { path: "settings", element: <Settings /> },
{ {
@@ -33,6 +36,9 @@ const router = createBrowserRouter([
element: <ComicDetailContainer />, element: <ComicDetailContainer />,
}, },
{ path: "import", element: <Import path={"./comics"} /> }, { path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> },
{ path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> },
], ],
}, },
]); ]);
@@ -40,6 +46,5 @@ const router = createBrowserRouter([
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>, </QueryClientProvider>,
); );

View File

@@ -1,10 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isNil } from "lodash";
import io from "socket.io-client"; import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints"; import { SOCKET_BASE_URI } from "../constants/endpoints";
import { produce } from "immer"; import { produce } from "immer";
import AirDCPPSocket from "../services/DcppSearchService"; import AirDCPPSocket from "../services/DcppSearchService";
import axios from "axios"; import axios from "axios";
import { QueryClient } from "@tanstack/react-query";
/* Broadly, this file sets up: /* Broadly, this file sets up:
* 1. The zustand-based global client state * 1. The zustand-based global client state
@@ -23,9 +24,15 @@ export const useStore = create((set, get) => ({
airDCPPSocketConnected: value, airDCPPSocketConnected: value,
})), })),
airDCPPDownloadTick: {}, airDCPPDownloadTick: {},
airDCPPTransfers: {},
// Socket.io state // Socket.io state
socketIOInstance: {}, socketIOInstance: {},
// ComicVine Scraping status
comicvine: {
scrapingStatus: "",
},
// Import job queue and associated statuses // Import job queue and associated statuses
importJobQueue: { importJobQueue: {
successfulJobCount: 0, successfulJobCount: 0,
@@ -68,6 +75,7 @@ export const useStore = create((set, get) => ({
})); }));
const { getState, setState } = useStore; const { getState, setState } = useStore;
const queryClient = new QueryClient();
/** Socket.IO initialization **/ /** Socket.IO initialization **/
// 1. Fetch sessionId from localStorage // 1. Fetch sessionId from localStorage
@@ -114,6 +122,52 @@ socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
})); }));
}); });
// 1a. Act on each comic issue successfully imported/failed, as indicated
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
const { completedJobCount, importResult } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
mostRecentImport: importResult.rawFileDetails.name,
},
}));
});
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
const { failedJobCount } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
failedJobCount,
},
}));
});
// 1b. Clear the localStorage sessionId upon receiving the
// LS_IMPORT_QUEUE_DRAINED event
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
localStorage.removeItem("sessionId");
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
status: "drained",
},
}));
console.log("a", queryClient);
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
// ComicVine Scraping status
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
setState((state) => ({
comicvine: {
...state.comicvine,
scrapingStatus: data.message,
},
}));
});
/** /**
* Method to init AirDC++ Socket with supplied settings * Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection * @param configuration - credentials, and hostname details to init AirDC++ connection
@@ -215,5 +269,3 @@ if (!isNil(directConnectConfiguration)) {
} else { } else {
console.log("problem"); console.log("problem");
} }
console.log("connected?", getState());

25
tailwind.config.ts Normal file
View File

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

3685
yarn.lock

File diff suppressed because it is too large Load Diff