Compare commits

..

33 Commits

Author SHA1 Message Date
90690c6876 Re-added yarn.lock 2023-11-07 11:44:07 -06:00
08ff21a987 AirDCPP code cleaned up from App.tsx 2023-11-07 11:44:04 -06:00
07724380f7 🧸 Zustand-ified AirDCPP Form 2023-11-07 11:44:04 -06:00
5b27658dc3 🐛 Debugging AirDCPP Settings Form page 2023-11-07 11:44:04 -06:00
91d0110de2 🔧 Fixed early init error on the store 2023-11-07 11:44:04 -06:00
730141ff26 🔧 Removing context from AirDCPP settings page 2023-11-07 11:44:04 -06:00
59801763e6 🔧 Removed context 2023-11-07 11:44:02 -06:00
7639833757 🏗️ Refactoring AirDCPP Settings Form with react-query 2023-11-07 11:43:56 -06:00
a0f7280fbb 🏗️ Refactored AirDCPP context using react-query 2023-11-07 11:43:56 -06:00
1f8dded15e 🏗 Added error check state 2023-11-07 11:43:56 -06:00
c3b9ad9d91 🏗️ Added loading state check 2023-11-07 11:43:56 -06:00
e89a7f9c91 📝 qbittorrent form RU actions first draft 2023-11-07 11:43:56 -06:00
2a6eeaf943 🧩 Added react-query query to qBittorrentSettings page 2023-11-07 11:43:56 -06:00
d45c53dff9 🏗️ Trying out react-query 2023-11-07 11:43:56 -06:00
2df0fce792 ⬇️ Fetched qBittorrent settings 2023-11-07 11:43:54 -06:00
4ea9086e3f Migrating from Redux to RTK-query 2023-11-07 11:43:54 -06:00
206c2eeb4b 🏗️ Reorganized tabs 2023-11-07 11:43:51 -06:00
6cd6612ce1 📝 Abstracted connection form into a component 2023-11-07 11:43:51 -06:00
538ca61fa8 📝 Purified Card Component 2023-11-07 11:43:51 -06:00
69cebe82dd 🔧 Added Prowlarr settings items in JSON 2023-11-07 11:43:51 -06:00
c6e55a90ce 🔧 Formatting and more layout changes 2023-11-07 11:43:51 -06:00
3ca15148f5 🏗️ Adding fields to the settings form 2023-11-07 11:43:51 -06:00
bd35223f0f 🧑🏼‍🔧 Fixed the hostname regex 2023-11-07 11:43:51 -06:00
4e82bc73e1 ⬆️ Bumped @dnd-kit deps 2023-11-07 11:43:51 -06:00
ac80c23fa8 📝 Qbittorrent form scaffold 2023-11-07 11:43:47 -06:00
f9f3109132 🔧 Tooling changes 2023-11-07 11:43:33 -06:00
111962acd1 🔧 More path fixes 2023-11-07 11:43:10 -06:00
502ed707ab 🔧 Cleaned up Search and Import component hierarchy 2023-11-07 11:43:10 -06:00
0058df3859 🔧 Fixed broken paths 2023-11-07 11:43:10 -06:00
8c4027f383 🔧 Cleaned up folder structure 2023-11-07 11:43:10 -06:00
9fe69d21db 🔧 Some refactoring 2023-11-07 11:43:10 -06:00
c591f1db2f 🔧 Added scaffold for the qBittorrent connection form 2023-11-07 11:43:09 -06:00
093dcc448c 🌊 qBittorrent settings scaffold 2023-11-07 11:43:09 -06:00
84 changed files with 5322 additions and 6608 deletions

1
.gitignore vendored
View File

@@ -4,7 +4,6 @@ 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,25 +6,14 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots ### Screenshots
#### Dashboard ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg) ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png)
#### Issue View ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg) ![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png)
#### 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
@@ -39,8 +28,7 @@ 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-acquisition-service](https://github.com/rishighan/threetwo-acquisition-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.
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
@@ -55,18 +43,20 @@ 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,14 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Three Two!</title>
</head>
<body class="dark:bg-slate-600"> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Three Two!</title>
</head>
<body>
<div id="root"></div> <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.1.0", "version": "0.0.2",
"description": "ThreeTwo! A good comic book curator.", "description": "ThreeTwo! A 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",
"@popperjs/core": "^2.11.8", "@redux-devtools/extension": "^3.2.5",
"@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": "^4.2.1", "@vitejs/plugin-react": "^3.1.0",
"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,12 +38,9 @@
"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",
"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",
@@ -51,32 +48,33 @@
"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.10.0", "react-day-picker": "^8.6.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.3.2",
"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.0.7",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.5", "vite": "^4.5.0",
"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",
@@ -96,8 +94,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",
@@ -112,18 +110,15 @@
"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.66.1",
"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": { "resolutions": {
"jackspeak": "2.1.1" "styled-components": "^5"
} }
} }

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

View File

@@ -33,9 +33,9 @@ interface SearchData {
priority: PriorityEnum; priority: PriorityEnum;
} }
export const sleep = (ms: number): Promise<NodeJS.Timeout> => { function sleep(ms: number): Promise<NodeJS.Timeout> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}; }
export const toggleAirDCPPSocketConnectionStatus = export const toggleAirDCPPSocketConnectionStatus =
(status: String, payload?: any) => async (dispatch) => { (status: String, payload?: any) => async (dispatch) => {
@@ -59,6 +59,83 @@ export const toggleAirDCPPSocketConnectionStatus =
break; break;
} }
}; };
export const search =
(data: SearchData, ADCPPSocket: any, credentials: any) =>
async (dispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
const instance: SearchInstance = await ADCPPSocket.post("search");
dispatch({
type: AIRDCPP_SEARCH_IN_PROGRESS,
});
// We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
dispatch({
type: AIRDCPP_SEARCH_RESULTS_ADDED,
groupedResult,
});
},
instance.id,
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
dispatch({
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
groupedResult,
});
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
dispatch({
type: AIRDCPP_HUB_SEARCHES_SENT,
searchInfo,
instance,
});
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
};
export const downloadAirDCPPItem = export const downloadAirDCPPItem =
( (
searchInstanceId: Number, searchInstanceId: Number,

View File

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

View File

@@ -1,15 +1,52 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useContext, useEffect } from "react";
import { Outlet } from "react-router-dom"; import Dashboard from "./Dashboard/Dashboard";
import { Navbar2 } from "./shared/Navbar2"; import Import from "./Import/Import";
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
import LibraryGrid from "./Library/LibraryGrid";
import Search from "./Search/Search";
import Settings from "./Settings/Settings";
import VolumeDetail from "./VolumeDetail/VolumeDetail";
import Downloads from "./Downloads/Downloads";
import { Routes, Route } from "react-router-dom";
import Navbar from "./shared/Navbar";
import "../assets/scss/App.scss"; import "../assets/scss/App.scss";
import { SocketIOProvider } from "../context/SocketIOContext";
import socketIOConnectionInstance from "../shared/socket.io/instance";
import { isEmpty, isNil, isUndefined } from "lodash";
import {
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
LS_SINGLE_IMPORT,
} from "../constants/action-types";
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
export const App = (): ReactElement => { export const App = (): ReactElement => {
return ( // useEffect(() => {
<> // // Check if there is a sessionId in localStorage
<Navbar2 /> // const sessionId = localStorage.getItem("sessionId");
<Outlet /> // if (!isNil(sessionId)) {
</> // // Resume the session
); // dispatch({
// type: "RESUME_SESSION",
// meta: { remote: true },
// session: { sessionId },
// });
// } else {
// // Inititalize the session and persist the sessionId to localStorage
// socketIOConnectionInstance.on("sessionInitialized", (sessionId) => {
// localStorage.setItem("sessionId", sessionId);
// });
// }
// }, []);
return <>{/* The rest of your application */}</>;
}; };
export default App; export default App;

View File

@@ -1,15 +1,21 @@
import React, { useCallback, ReactElement, useEffect, useState } from "react"; import React, {
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions"; useCallback,
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; useContext,
ReactElement,
useEffect,
useState,
} from "react";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState, SearchInstance } from "threetwo-ui-typings"; import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store"; import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
@@ -21,268 +27,162 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = ( export const AcquisitionPanel = (
props: IAcquisitionPanelProps, props: IAcquisitionPanelProps,
): ReactElement => { ): ReactElement => {
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
airDCPPDownloadTick,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
})),
);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
const { comicObjectId } = props;
const issueName = props.query.issue.name || ""; const 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);
const dispatch = useDispatch();
const [dcppQuery, setDcppQuery] = useState({}); const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
const queryClient = useQueryClient();
// Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that
// All the user has to do is hit "Search AirDC++"
useEffect(() => { useEffect(() => {
// AirDC++ search query if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
const dcppSearchQuery = { // AirDC++ search query
query: { const dcppSearchQuery = {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`, query: {
extensions: ["cbz", "cbr", "cb7"], pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
}, extensions: ["cbz", "cbr", "cb7"],
hub_urls: map(hubs, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, []);
/**
* Method to perform a search via an AirDC++ websocket
* @param {SearchData} data - a SearchData query
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
*/
const search = async (data: SearchData, ADCPPSocket: any) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
const instance: SearchInstance = await ADCPPSocket.post("search");
setAirDCPPSearchStatus(true);
// We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
setAirDCPPSearchResults((state) => [...state, groupedResult]);
}, },
instance.id, hub_urls: map(
); airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories ),
await ADCPPSocket.addListener( priority: 5,
`search`, };
"search_result_updated", setDcppQuery(dcppSearchQuery);
async (groupedResult) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.result.id === groupedResult.result.id,
);
const updatedState = [...airDCPPSearchResults];
if (
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) {
updatedState[bundleToUpdateIndex] = groupedResult;
}
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
setAirDCPPSearchInstance(instance);
setAirDCPPSearchStatus(false);
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
} }
}; }, [airDCPPConfiguration]);
/** const getDCPPSearchResults = useCallback(
* Method to download a bundle associated with a search result from AirDC++ async (searchQuery) => {
* @param {Number} searchInstanceId - description const manualQuery = {
* @param {String} resultId - description query: {
* @param {String} comicObjectId - description pattern: `${searchQuery.issueName}`,
* @param {String} name - description extensions: ["cbz", "cbr", "cb7"],
* @param {Number} size - description },
* @param {any} type - description hub_urls: map(
* @param {any} ADCPPSocket - description airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
* @returns {void} - description (item) => item.value,
*/ ),
const download = async ( priority: 5,
searchInstanceId: Number, };
resultId: String, dispatch(
comicObjectId: String, search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
name: String, username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
size: Number, password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
type: any, }),
ADCPPSocket: any,
): void => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
); );
},
[dispatch, airDCPPConfiguration],
);
if (!isNil(downloadResult)) { // download via AirDC++
bundleDBImportResult = await axios({ const downloadDCPPResult = useCallback(
method: "POST", (searchInstanceId, resultId, name, size, type) => {
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`, dispatch(
headers: { downloadAirDCPPItem(
"Content-Type": "application/json; charset=utf-8", 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}`,
}, },
data: { ),
bundleId: downloadResult.bundle_info.id, );
comicObjectId, // this is to update the download count badge on the downloads tab
name, dispatch(
size, getBundlesForComic(
type, props.comicObjectId,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
}, },
}); ),
console.log(bundleDBImportResult?.data); );
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] }); },
[airDCPPConfiguration],
// dispatch({ );
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
// downloadResult,
// bundleDBImportResult,
// });
//
// dispatch({
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
// comicBookDetail: bundleDBImportResult.data,
// IMS_inProgress: false,
// });
}
} catch (error) {
throw error;
}
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (hub) => hub.hub_url),
priority: 5,
};
search(manualQuery, airDCPPSocketInstance);
};
return ( return (
<> <>
<div className="mt-5"> <div className="comic-detail columns">
{!isEmpty(airDCPPSocketInstance) ? ( {!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
issueName, issueName,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}> <form
<Field name="issueName"> onSubmit={handleSubmit}
{({ input, meta }) => { className="column is-three-quarters"
return ( >
<div className="max-w-fit"> <div className="box search">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg"> <div className="columns">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200"> <Field name="issueName">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" /> {({ input, meta }) => {
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info">
Use this to perform a manual search.
</span>
</div> </div>
<input );
{...input} }}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300" </Field>
placeholder="Type an issue/volume name"
/>
<button <div className="column">
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" <button
type="submit" type="submit"
> className={
<div className="flex flex-row"> isAirDCPPSearchInProgress
Search DC++ ? "button is-loading is-warning"
<div className="h-5 w-5 ml-2"> : "button"
<img }
src="/src/client/assets/img/airdcpp_logo.svg" >
className="h-5 w-5" <span className="icon is-small">
/> <img src="/src/client/assets/img/airdcpp_logo.svg" />
</div> </span>
</div> <span className="airdcpp-text">Search on AirDC++</span>
</button> </button>
</div> </div>
</div> </div>
); </div>
}}
</Field>
</form> </form>
)} )}
/> />
) : ( ) : (
<div className=""> <div className="column is-three-fifths">
<article className=""> <article className="message is-info">
<div className=""> <div className="message-body is-size-6 is-family-secondary">
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</code>.
</div> </div>
</article> </article>
</div> </div>
@@ -290,127 +190,91 @@ export const AcquisitionPanel = (
</div> </div>
{/* AirDC++ search instance details */} {/* AirDC++ search instance details */}
{!isNil(airDCPPSearchInstance) && {!isNil(searchInfo) && !isNil(searchInstance) && (
!isEmpty(airDCPPSearchInfo) && <div className="columns">
!isNil(hubs) && ( <div className="column is-one-quarter is-size-7">
<div className="flex flex-row gap-3 my-5 font-hasklig"> <div className="card">
<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"> <div className="card-content">
<dl> <dl>
<dt> <dt>
<div className="mb-1"> <div className="tags mb-1">
{hubs.map((value, idx) => ( {airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
<span className="tag is-warning" key={idx}> ({ value }) => (
{value.identity.name} <span className="tag is-warning" key={value}>
</span> {value}
))} </span>
</div> ),
</dt> )}
</div>
<dt> </dt>
Query: <dt>
<span className="has-text-weight-semibold"> Query:{" "}
{airDCPPSearchInfo.query.pattern} <span className="has-text-weight-semibold">
</span> {searchInfo.query.pattern}
</dt> </span>
<dd> </dt>
Extensions: <dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<span className="has-text-weight-semibold"> <dd>File type: {searchInfo.query.file_type}</dd>
{airDCPPSearchInfo.query.extensions.join(", ")} </dl>
</span> </div>
</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>
</div> </div>
)} <div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */} {/* AirDC++ results */}
<div className="columns"> <div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( {!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500"> <div className="column">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md"> <table className="table">
<thead> <thead>
<tr> <tr>
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Name</th>
Name <th>Type</th>
</th> <th>Slots</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Actions</th>
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 className="divide-y divide-slate-100 dark:divide-gray-500"> <tbody>
{map(airDCPPSearchResults, ({ result }, idx) => { {map(airDCPPSearchResults, ({ result }, idx) => {
return ( return (
<tr <tr
key={idx} key={idx}
className={ className={
!isNil(result.dupe) !isNil(result.dupe) ? "dupe-search-result" : ""
? "bg-gray-100 dark:bg-gray-700"
: "w-fit text-sm"
} }
> >
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300"> <td>
<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>
) : null} ) : null}{" "}
{ellipsize(result.name, 70)} {ellipsize(result.name, 70)}
</p> </p>
<dl> <dl>
<dd> <dd>
<div className="inline-flex flex-row gap-2"> <div className="tags">
{!isNil(result.dupe) ? ( {!isNil(result.dupe) ? (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="tag is-warning">Dupe</span>
<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">
{/* Nicks */} {result.users.user.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="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="tag is-light" key={idx}>
<span className="pr-1 pt-1"> {flag}
<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>
@@ -418,49 +282,36 @@ export const AcquisitionPanel = (
</dl> </dl>
</td> </td>
<td> <td>
{/* Extension */} <span className="tag is-light is-info">
<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.type.id === "directory"
<span className="pr-1 pt-1"> ? "directory"
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i> : result.type.str}
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.type.str}
</span>
</span> </span>
</td> </td>
<td className="px-2"> <td>
{/* Slots */} <div className="tags has-addons">
<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="tag is-success">
<span className="pr-1 pt-1"> {result.slots.free} free
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
</span> </span>
<span className="tag is-light">
<span className="text-md text-slate-500 dark:text-slate-900"> {result.slots.total}
{result.slots.total} slots; {result.slots.free} free
</span> </span>
</span> </div>
</td> </td>
<td className="px-2"> <td>
<button <a
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={() =>
download( downloadDCPPResult(
airDCPPSearchInstance.id, searchInstance.id,
result.id, result.id,
comicObjectId,
result.name, result.name,
result.size, result.size,
result.type, result.type,
airDCPPSocketInstance,
) )
} }
> >
<span className="text-xs">Download</span> <i className="fas fa-file-download"></i>
<span className="w-5 h-5"> </a>
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span>
</button>
</td> </td>
</tr> </tr>
); );
@@ -469,28 +320,13 @@ export const AcquisitionPanel = (
</table> </table>
</div> </div>
) : ( ) : (
<div className=""> <div className="column is-three-fifths">
<article <article className="message is-info">
role="alert" <div className="message-body is-size-6 is-family-secondary">
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
The default search term is an auto-detected title; you may need
to change it to get better matches if the auto-detected one
doesn't work.
</div>
</article>
<article
role="alert"
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
Searching via <strong>AirDC++</strong> is still in{" "} Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long, <strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from{" "} or may not work at all. Searches from <code>ADCS</code> hubs are
<code className="font-hasklig">ADCS</code> hubs are more more reliable than <code>NMDCS</code> ones.
reliable than <code className="font-hasklig">NMDCS</code> ones.
</div> </div>
</article> </article>
</div> </div>
@@ -500,4 +336,4 @@ export const AcquisitionPanel = (
); );
}; };
export default AcquisitionPanel; export default AcquisitionPanel;

View File

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

@@ -1,51 +0,0 @@
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

@@ -1,4 +1,5 @@
import React, { useState, ReactElement, useCallback } from "react"; import React, { useState, ReactElement, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel"; import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
@@ -12,12 +13,10 @@ 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, filter } from "lodash"; import { isEmpty, isUndefined, isNil } 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";
@@ -29,10 +28,6 @@ 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 = {};
/** /**
@@ -52,9 +47,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo }, sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
updatedAt,
}, },
userSettings, userSettings,
} = data; } = data;
@@ -62,29 +54,37 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false); const [modalIsOpen, setIsOpen] = useState(false);
const [comicVineMatches, setComicVineMatches] = useState([]);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const comicVineSearchQueryObject = useSelector(
(state: RootState) => state.comicInfo.searchQuery,
);
const comicVineAPICallProgress = useSelector(
(state: RootState) => state.comicInfo.inProgress,
);
const extractedComicBook = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.reading,
);
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
// const dispatch = useDispatch(); const dispatch = useDispatch();
const openModal = useCallback((filePath) => { const openModal = useCallback((filePath) => {
setIsOpen(true); setIsOpen(true);
// dispatch( dispatch(
// extractComicArchive(filePath, { extractComicArchive(filePath, {
// type: "full", type: "full",
// purpose: "reading", purpose: "reading",
// imageResizeOptions: { imageResizeOptions: {
// baseWidth: 1024, baseWidth: 1024,
// }, },
// }), }),
// ); );
}, []); }, []);
// 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,183 +100,57 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
CVMatches: { CVMatches: {
content: (props) => ( content: (props) => (
<> <>
<div> <div className="card search-criteria-card">
<ComicVineSearchForm data={rawFileDetails} /> <div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
</div> </div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
<div className="border-slate-500 border rounded-lg p-2 mt-3"> {inferredMetadata.issue ? (
<p className="">Searching for:</p> <div className="ml-3">
{inferredMetadata.issue ? ( <span className="tag mr-3">{inferredMetadata.issue.name} </span>
<> <span className="tag"> # {inferredMetadata.issue.number} </span>
<span className="">{inferredMetadata.issue.name} </span> </div>
<span className=""> # {inferredMetadata.issue.number} </span> ) : null}
</> {!comicVineAPICallProgress ? (
) : null} <ComicVineMatchPanel
</div> props={{
<ComicVineMatchPanel comicVineSearchQueryObject,
props={{ comicVineAPICallProgress,
comicVineMatches, comicVineSearchResults,
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 data={rawFileDetails} />, content: () => <EditMetadataPanel />,
}, },
}; };
// 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); !isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
const { issueName, url } = determineCoverFile({ const { issueName, url } = determineCoverFile({
rawFileDetails, rawFileDetails,
@@ -296,9 +170,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 1, id: 1,
name: "Volume Information", name: "Volume Information",
icon: ( icon: <i className="fa-solid fa-layer-group"></i>,
<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,
@@ -307,30 +179,26 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{ {
id: 2, id: 2,
name: "ComicInfo.xml", name: "ComicInfo.xml",
icon: ( icon: <i className="fa-solid fa-code"></i>,
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: ( content: (
<div key={2}> <div className="columns" key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />} <div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div> </div>
), ),
shouldShow: !isEmpty(comicInfo), shouldShow: !isEmpty(comicInfo),
}, },
{ {
id: 3, id: 3,
icon: ( icon: <i className="fa-regular fa-file-archive"></i>,
<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: <ArchiveOperations data={data.data} key={3} />, content: <ArchiveOperations data={data.data} key={3} />,
shouldShow: areRawFileDetailsAvailable, shouldShow: areRawFileDetailsAvailable,
}, },
{ {
id: 4, id: 4,
icon: ( icon: <i className="fa-solid fa-circle-nodes"></i>,
<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
@@ -345,37 +213,26 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
}, },
{ {
id: 5, id: 5,
icon: ( icon: <i className="fa-solid fa-droplet"></i>,
<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: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />, content: <>Torrents</>,
shouldShow: true, shouldShow: true,
}, },
{ {
id: 6, id: 6,
name: "Downloads", icon: null,
icon: ( name: !isEmpty(data.data) ? (
<> <span className="download-tab-name">Downloads</span>
{acquisition?.directconnect?.downloads?.length + ) : (
acquisition?.torrent.length} "Downloads"
</> ),
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,
}, },
]; ];
@@ -387,74 +244,73 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// 2. from the CV-scraped version // 2. from the CV-scraped version
return ( return (
<section className="container mx-auto"> <section className="container">
<div className="section"> <div className="section">
{!isNil(data) && !isEmpty(data) && ( {!isNil(data) && !isEmpty(data) && (
<> <>
<div> <h1 className="title">{issueName}</h1>
<div className="flex flex-row mt-5"> <div className="columns is-multiline">
<div className="column is-narrow">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation={"vertical"}
hasDetails={false} hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }}
/> />
{/* action dropdown */}
{/* raw file details */} <div className="mt-4 is-size-7">
<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)}
> >
{/* action dropdown */} <i className="fa-solid fa-book-open mr-2"></i>
<div className="mt-1 flex flex-row gap-2 w-full"> Read
<Menu </button>
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }} <Modal
configuration={{ style={{ content: { marginTop: "2rem" } }}
filteredActionOptions, isOpen={modalIsOpen}
customStyles, onAfterOpen={afterOpenModal}
handleActionSelection, onRequestClose={closeModal}
Placeholder, contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}} }}
/> />
</div> )}
</RawFileDetails> </Modal>
</>
{/* <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}
downloadCount={acquisition?.directconnect?.downloads?.length}
/>
<StyledSlidingPanel <SlidingPane
isOpen={visible} isOpen={visible}
onRequestClose={() => setVisible(false)} onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"} title={"Comic Vine Search Matches"}
@@ -462,7 +318,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
> >
{slidingPanelContentId !== "" && {slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()} contentForSlidingPanel[slidingPanelContentId].content()}
</StyledSlidingPanel> </SlidingPane>
</> </>
)} )}
</div> </div>

View File

@@ -1,35 +1,22 @@
import React, { ReactElement } from "react"; import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useParams } from "react-router-dom"; import { 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 { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
export const ComicDetailContainer = (): ReactElement | null => { export const ComicDetailContainer = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const comicBookDetailData = useSelector(
const { (state: RootState) => state.comicInfo.comicBookDetail,
data: comicBookDetailData,
isLoading,
isError,
} = useQuery({
queryKey: ["comicBookMetadata"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
); );
const dispatch = useDispatch();
const { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
) : null;
}; };

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 { isEmpty, isUndefined } from "lodash"; import { 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="text-slate-500 dark:text-gray-400"> <div className="column is-half">
<div className=""> <div className="comic-detail comicvine-metadata">
<div> <dl>
<div className="flex flex-row gap-4"> <dt>ComicVine Metadata</dt>
<div className="min-w-fit"> <dd className="is-size-7">
<Card Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
imageUrl={data.volumeInformation.image.thumb_url} </dd>
orientation={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
</div>
{/* Comicvine metadata */} <dd>
<div className="mt-2"> <div className="columns mt-2">
<div className="text-md">ComicVine Metadata</div> <div className="column is-2">
<div className="text-sm"> <Card
Last scraped on{" "} imageUrl={data.volumeInformation.image.thumb_url}
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")} orientation={"vertical"}
</div> hasDetails={false}
<div className="text-sm"> // cardContainerStyle={{ maxWidth: 200 }}
ComicVine Issue ID />
<span>{data.id}</span> </div>
</div> <div className="column is-10">
</div> <dl>
</div> <dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
{/* Publisher details */} </dt>
<div className="ml-8"> <dd>
Published by{" "} Is a part of{" "}
<span>{data.volumeInformation.publisher.name}</span> <span className="has-text-info">
<div> {data.volumeInformation.name}
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> </span>
</div> </dd>
<div>
{data.issue_number && ( <dd>
<div className=""> Published by
<span>Issue Number</span> <span className="has-text-weight-semibold">
<span>{data.issue_number}</span> {" "}
{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>
{!isUndefined( </dd>
detectIssueTypes(data.volumeInformation.description), </dl>
) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
</span>
</div>
) : data.resource_type ? (
<div>
<span>Type</span>
<span>{data.resource_type}</span>
</div>
) : null}
</div>
</div>
</div>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div> </div>
</div> </div>
</div> </dd>
</div> </dl>
</div> </div>
</div> </div>
); );

View File

@@ -2,41 +2,22 @@ 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 { comicObjectId, comicVineMatches } = comicVineData.props; const {
const { comicvine } = useStore( comicObjectId,
useShallow((state) => ({ comicVineSearchQueryObject,
comicvine: state.comicvine, comicVineAPICallProgress,
})), comicVineSearchResults,
); } = comicVineData.props;
return ( return (
<> <>
<div> <div className="search-results-container">
{!isEmpty(comicVineMatches) ? ( {!isEmpty(comicVineSearchResults) && (
<MatchResult <MatchResult
matchData={comicVineMatches} matchData={comicVineSearchResults}
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

@@ -2,6 +2,7 @@ import React, { useCallback } from "react";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible"; import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../../actions/fileops.actions"; import { fetchComicVineMatches } from "../../actions/fileops.actions";
import { useDispatch } from "react-redux";
/** /**
* Component for performing search against ComicVine * Component for performing search against ComicVine
@@ -13,6 +14,7 @@ import { fetchComicVineMatches } from "../../actions/fileops.actions";
* ) * )
*/ */
export const ComicVineSearchForm = (data) => { export const ComicVineSearchForm = (data) => {
const dispatch = useDispatch();
const onSubmit = useCallback((value) => { const onSubmit = useCallback((value) => {
const userInititatedQuery = { const userInititatedQuery = {
inferredIssueDetails: { inferredIssueDetails: {
@@ -22,7 +24,7 @@ export const ComicVineSearchForm = (data) => {
year: value.issueYear, year: value.issueYear,
}, },
}; };
// dispatch(fetchComicVineMatches(data, userInititatedQuery)); dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []); }, []);
const validate = () => { const validate = () => {
return true; return true;
@@ -34,55 +36,83 @@ export const ComicVineSearchForm = (data) => {
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<span className="flex items-center"> <span className="field is-normal">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5"> <label className="label mb-2 is-size-5">Search Manually</label>
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span> </span>
<label className="block py-1">Issue Name</label> <div className="field is-horizontal">
<Field name="issueName"> <div className="field-body">
{(props) => ( <div className="field">
<input <Field name="issueName">
{...props.input} {(props) => (
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" <p className="control is-expanded has-icons-left">
placeholder="Type the issue name" <input
/> {...props.input}
)} className="input is-normal"
</Field> placeholder="Type the issue name"
<div className="flex flex-row gap-4"> />
<div> <span className="icon is-small is-left">
<label className="block py-1">Number</label> <i className="fas fa-journal-whills"></i>
<Field name="issueNumber"> </span>
{(props) => ( </p>
<input )}
{...props.input} </Field>
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" </div>
placeholder="#"
/>
)}
</Field>
</div>
<div>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
</div> </div>
</div>
<div className="flex justify-end mt-5"> <div className="field is-horizontal">
<button <div className="field-body">
type="submit" <div className="field">
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" <Field name="issueNumber">
> {(props) => (
Search <p className="control has-icons-left">
</button> <input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div> </div>
</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-5">{props.data.name}</h4> <h4 className="is-size-6">{props.data.name}</h4>
<div> <div>
<span className="is-size-4 has-text-weight-semibold"> <span className="is-size-3 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,12 +20,13 @@ export const DownloadProgressTick = (props): ReactElement => {
% %
</progress> </progress>
</div> </div>
<div className="is-size-6 mt-1 mb-2"> <div className="is-size-5">
<p>{prettyBytes(props.data.speed)} per second.</p> {prettyBytes(props.data.speed)} per second.
</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,147 +1,106 @@
import React, { useEffect, useContext, ReactElement, useState } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings"; import { RootState } from "threetwo-ui-typings";
import { isEmpty, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles"; import prettyBytes from "pretty-bytes";
import { TorrentDownloads } from "./TorrentDownloads"; import dayjs from "dayjs";
import { useQuery } from "@tanstack/react-query"; import ellipsize from "ellipsize";
import axios from "axios"; import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
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 {
key: number; data: any;
comicObjectId: string;
} }
export const DownloadsPanel = ( export const DownloadsPanel = (
props: IDownloadsPanelProps, props: IDownloadsPanelProps,
): ReactElement | null => { ): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const bundles = useSelector((state: RootState) => {
const [bundles, setBundles] = useState([]); return state.airdcpp.bundles;
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState([]);
const [activeTab, setActiveTab] = useState("torrents");
const { airDCPPSocketInstance, socketIOInstance } = useStore(
useShallow((state: any) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
socketIOInstance: state.socketIOInstance,
})),
);
// React to torrent progress data sent over websockets
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
const torrents = data.torrents
.flatMap(({ _id, details }) => {
if (_id === comicObjectId) {
return details;
}
})
.filter((item) => item !== undefined);
setTorrentDetails(torrents);
}); });
// AirDCPP Socket initialization
const userSettings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++ // Fetch the downloaded files and currently-downloading file(s) from AirDC++
const { data: comicObject, isSuccess } = useQuery({ useEffect(() => {
queryKey: ["bundles"], try {
queryFn: async () => if (!isEmpty(userSettings)) {
await axios({ dispatch(
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`, getBundlesForComic(props.comicObjectId, socket, {
method: "POST", username: `${settings.directConnect.client.host.username}`,
headers: { password: `${settings.directConnect.client.host.password}`,
"Content-Type": "application/json; charset=utf-8", }),
},
data: {
id: `${comicObjectId}`,
},
}),
});
const getBundles = async (comicObject) => {
if (comicObject?.data.acquisition.directconnect) {
const filteredBundles =
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
},
); );
return await Promise.all(filteredBundles); }
} catch (error) {
throw new Error(error);
} }
}, [dispatch, airDCPPConfiguration]);
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
}; };
// Call the scheduled job for fetching torrent data return !isNil(props.data) ? (
// triggered by the active tab been set to "torrents" <>
const { data: torrentData } = useQuery({ <div className="columns is-multiline">
queryFn: () => {!isEmpty(socket) ? (
axios({ <Bundles data={bundles} />
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`, ) : (
method: "GET", <div className="column is-three-fifths">
params: { <article className="message is-info">
trigger: activeTab, <div className="message-body is-size-6 is-family-secondary">
}, AirDC++ is not configured. Please configure it in{" "}
}), <code>Settings</code>.
queryKey: [activeTab], </div>
}); </article>
</div>
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> </div>
</>
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />} ) : null;
</div>
);
}; };
export default DownloadsPanel; export default DownloadsPanel;

View File

@@ -1,4 +1,5 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import arrayMutators from "final-form-arrays"; import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays"; import { FieldArray } from "react-final-form-arrays";
@@ -8,9 +9,7 @@ import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => { 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
@@ -29,9 +28,10 @@ export const EditMetadataPanel = (props): ReactElement => {
/> />
); );
}; };
// const rawFileDetails = useSelector( const rawFileDetails = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name, (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
// ); );
const dispatch = useDispatch();
return ( return (
<> <>
@@ -58,59 +58,90 @@ 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">
<Field <div className="field">
name="issue_name" <p className="control is-expanded has-icons-left">
component="input" <Field
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" name="issue_name"
initialValue={data.name} component="input"
placeholder={"Issue Name"} className="input"
/> initialValue={rawFileDetails}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
</div> </div>
</div> </div>
{/* Issue Number and year */} {/* Issue Number and year */}
<div className="mt-4 flex flex-row gap-2"> <div className="field is-horizontal">
<div> <div className="field-label"></div>
<div className="text-sm">Issue Number</div> <div className="field-body">
<Field <div className="field">
name="issue_number" <p className="control has-icons-left">
component="input" <Field
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" name="issue_number"
placeholder="Issue Number" component="input"
/> className="input"
<p className="text-xs">Do not enter the first zero</p> placeholder="Issue Number"
</div> />
<div> <span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
</div>
{/* year */} {/* year */}
<div className="text-sm">Issue Year</div> <div className="field">
<Field <p className="control">
name="issue_year" <Field
component="input" name="issue_year"
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"
/> 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="mt-2"> <div className="field is-horizontal">
<label className="text-sm">Description</label> <div className="field-label is-normal">
<Field <label className="label">Description</label>
name={"description"} </div>
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-body">
component={TextareaAutosizeAdapter} <div className="field">
placeholder={"Description"} <p className="control is-expanded has-icons-left">
/> <Field
name={"description"}
className="textarea"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
</div> </div>
<hr size="1" /> <hr size="1" />

View File

@@ -1,9 +1,9 @@
import React from "react"; import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
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;
@@ -15,24 +15,15 @@ const handleBrokenImage = (e) => {
}; };
export const MatchResult = (props: MatchResultProps) => { export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (match, comicObjectId) => { const dispatch = useDispatch();
return await axios.request({ const applyCVMatch = useCallback(
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`, (match, comicObjectId) => {
method: "POST", dispatch(applyComicVineMatch(match, comicObjectId));
data: { },
match, [dispatch],
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)) {
@@ -42,90 +33,88 @@ export const MatchResult = (props: MatchResultProps) => {
}, },
}); });
} }
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return ( return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}> <div className="search-result mb-4" key={idx}>
<div className="flex flex-row gap-4"> <div className="columns">
<div className="min-w-fit"> <div className="column is-one-fifth">
<img <img
className="rounded-md" className="cover-image"
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}
{/* score */} <div className="search-result-details column">
<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"> <div className="is-size-5">{match.name}</div>
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i> <div className="field is-grouped is-grouped-multiline mt-1">
</span> <div className="control">
<span className="text-slate-900 dark:text-slate-900"> <div className="tags has-addons">
{parseInt(match.score, 10)} <span className="tag">Number</span>
</span> <span className="tag is-primary">
</span> {match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div> </div>
<span className="flex flex-row gap-2 mb-2"> <div className="is-size-7">
<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="flex flex-row gap-2 my-4 ml-10"> <div className="vertical-line"></div>
<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="rounded-md w-full" className="cover-image"
onError={handleBrokenImage} onError={handleBrokenImage}
/> />
</div> </div>
<div className=""> <div className="column">
<span>{match.volume.name}</span> <div className="is-size-6">{match.volume.name}</div>
<div className="text-sm"> <div className="field is-grouped is-grouped-multiline mt-2">
<p> <div className="control">
Total Issues: <div className="tags has-addons">
{match.volumeInformation.results.count_of_issues} <span className="tag">Total Issues</span>
</p> <span className="tag is-warning">
<p> {match.volumeInformation.results.count_of_issues}
Published by{" "} </span>
{match.volumeInformation.results.publisher.name} </div>
</p> </div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="columns">
<button <div className="column">
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" <button
onClick={() => applyCVMatch(match, props.comicObjectId)} className="button is-normal is-outlined is-primary is-light is-pulled-right"
> onClick={() => applyCVMatch(match, props.comicObjectId)}
<span className="text-md">Apply Match</span> >
<span className="w-5 h-5"> <span className="icon is-size-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i> <i className="fas fa-clipboard-check"></i>
</span> </span>
</button> <span>Apply Match</span>
</button>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,134 +1,44 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
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(
const { socketIOInstance } = useStore( (state: RootState) => state.fileOps.comicBookExtractionInProgress,
useShallow((state) => ({
socketIOInstance: state.socketIOInstance,
})),
); );
const queryClient = useQueryClient(); const extractedComicBookArchive = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
);
const imageAnalysisResult = useSelector((state: RootState) => {
return state.fileOps.imageAnalysisResults;
});
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => {
dispatch(
extractComicArchive(data.rawFileDetails.filePath, {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
}),
);
}, []);
// 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 = {
@@ -136,13 +46,13 @@ export const ArchiveOperations = (props): ReactElement => {
content: () => { content: () => {
return ( return (
<div> <div>
<pre className="text-sm">{currentImage}</pre> <pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? ( {!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3"> <pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} /> <Canvas data={imageAnalysisResult} />
</pre> </pre>
) : null} ) : null}
<pre className="font-hasklig mt-3 text-sm"> <pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)} {JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre> </pre>
</div> </div>
@@ -154,81 +64,54 @@ export const ArchiveOperations = (props): ReactElement => {
// sliding panel handlers // sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => { const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis"); setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath); dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath); setCurrentImage(imageFilePath);
setVisible(true); setVisible(true);
}, []); }, []);
return ( return (
<div key={2}> <div key={2}>
<article <button
role="alert" className={
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" isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
> >
<div> <span className="icon is-small">
<p>You can perform several operations on your comic book archive.</p> <i className="fa-solid fa-box-open"></i>
<p> </span>
Uncompressing, re-organizing the individual pages, then <span>Unpack comic archive</span>
re-compressing to a different format, for example. </button>
</p> <div className="columns">
<p>You can also analyze color histograms of pages.</p> <div className="mt-5">
</div> {!isEmpty(extractedComicBookArchive) ? (
</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={uncompressedArchive} data={extractedComicBookArchive}
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}
@@ -243,4 +126,4 @@ export const ArchiveOperations = (props): ReactElement => {
); );
}; };
export default ArchiveOperations; export default ArchiveOperations;

View File

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

View File

@@ -1,15 +1,30 @@
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}>
<ComicVineDetails <div className="columns is-multiline">
data={data.sourcedMetadata.comicvine} <ComicVineDetails
updatedAt={data.updatedAt} data={data.sourcedMetadata.comicvine}
/> updatedAt={data.updatedAt}
/>
<div className="column is-8">
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
convert(data.sourcedMetadata.comicvine.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -1,77 +0,0 @@
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

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

View File

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

View File

@@ -1,165 +1,160 @@
import React, { ReactElement, useState } from "react"; import { isNil, map } from "lodash";
import { map } from "lodash"; import React, { createRef, ReactElement, useCallback, useEffect } from "react";
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;
}; };
const http = rateLimiter(axios.create(), { export const PullList = ({ issues }: PullListProps): ReactElement => {
maxRequests: 1, const dispatch = useDispatch();
perMilliseconds: 1000, useEffect(() => {
maxRPS: 1, dispatch(
}); getWeeklyPullList({
const cachedAxios = setupCache(axios); startDate: "2023-9-9",
export const PullList = (): ReactElement => { pageSize: "15",
// datepicker currentPage: "1",
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "M-dd-yyyy"),
);
// keen slider
const [sliderRef, instanceRef] = useKeenSlider(
{
loop: true,
slides: {
origin: "auto",
number: 15,
perView: 5,
spacing: 15,
},
slideChanged() {
console.log("slide changed");
},
},
[
// add plugins here
],
);
const {
data: pullList,
refetch,
isSuccess,
isLoading,
isError,
} = useQuery({
queryFn: async (): any =>
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
}), }),
queryKey: ["pullList", inputValue], );
}); }, []);
const addToLibrary = (sourceName: string, locgMetadata) => const addToLibrary = useCallback(
importToDB(sourceName, { locg: locgMetadata }); (sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
);
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
};
*/
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => { const next = () => {
// sliderRef.slickNext(); sliderRef.slickNext();
}; };
const previous = () => { const previous = () => {
// sliderRef.slickPrev(); sliderRef.slickPrev();
}; };
return ( return (
<> <>
<div className="content"> <div className="content">
<Header <Header headerContent="Discover"
headerContent="Discover" subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
subHeaderContent={ iconClassNames="fa-solid fa-binoculars mr-2"/>
<span className="text-md"> <div className="field is-grouped">
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="flex flex-row gap-4 my-3"> <div className="control">
<Form <div className="select is-small">
onSubmit={() => {}} <select>
render={({ handleSubmit }) => ( <option>Select Week</option>
<form> <option>With options</option>
<div className="flex flex-col gap-2"> </select>
{/* week selection for pull list */} </div>
<DatePickerDialog </div>
inputValue={inputValue} {/* See all pull list issues */}
setter={setInputValue} <div className="control">
/> <Link to={"/pull-list/all/"}>
{inputValue && ( <button className="button is-small">View all issues</button>
<div className="text-sm"> </Link>
Showing pull list for{" "} </div>
<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="field has-addons">
{inputValue} <div className="control">
</span> <button className="button is-rounded is-small" onClick={previous}>
</div> <i className="fa-solid fa-caret-left"></i>
)} </button>
</div> </div>
</form> <div className="control">
)} <button className="button is-rounded is-small" onClick={next}>
/> <i className="fa-solid fa-caret-right"></i>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{isSuccess && !isLoading && ( {!isNil(pullList) &&
<div ref={sliderRef} className="keen-slider flex flex-row"> pullList &&
{map(pullList?.data.result, (issue, idx) => { map(pullList, ({ issue }, idx) => {
return ( return (
<div key={idx} className="keen-slider__slide"> <Card
<Card key={idx}
orientation={"vertical-2"} orientation={"vertical"}
imageUrl={issue.cover} imageUrl={issue.cover}
hasDetails hasDetails
title={ellipsize(issue.name, 25)} title={ellipsize(issue.name, 18)}
> cardContainerStyle={{
<div className="px-1"> marginRight: 22,
<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"> boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
{issue.publisher} }}
</span> >
<div className="flex flex-row justify-end"> <div className="content">
<button <div className="control">
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" <span className="tag">{issue.publisher}</span>
onClick={() => addToLibrary("locg", issue)}
>
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want
</button>
</div>
</div> </div>
</Card> <div className="mt-2">
</div> <button
className="button is-small is-success is-outlined is-light"
onClick={() => addToLibrary("locg", issue)}
>
<i className="fa-solid fa-plus"></i> Want
</button>
</div>
</div>
</Card>
); );
})} })}
</div> </Slider>
)}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</> </>
); );
}; };

View File

@@ -4,35 +4,47 @@ 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 = {
comics: any; comicBookCovers: any;
}; };
export const RecentlyImported = ( export const RecentlyImported = ({
comics: RecentlyImportedProps, comicBookCovers,
): ReactElement => { }: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
return ( return (
<div> <>
<Header <div className="content mt-5">
headerContent="Recently Imported" <h4 className="title is-4">
subHeaderContent="Recent Library activity such as imports, tagging, etc." <i className="fa-solid fa-file-arrow-down"></i> Recently Imported
iconClassNames="fa-solid fa-binoculars mr-2" </h4>
/> <p className="subtitle is-7">
<div className="grid grid-cols-5 gap-6 mt-3"> Recent Library activity such as imports, tagging, etc.
{comics?.comics.map( </p>
</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 },
}, },
@@ -50,80 +62,85 @@ export const RecentlyImported = (
comicInfo, comicInfo,
locg, locg,
}); });
const isComicVineMetadataAvailable = const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return ( return (
<Card <React.Fragment key={_id}>
orientation="vertical-2" <Card
key={idx} orientation={"vertical"}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`} imageUrl={url}
title={inferredMetadata.issue.name} hasDetails
hasDetails title={issueName ? titleElement : null}
> >
<div> <div className="content is-flex is-flex-direction-row">
<dd className="text-sm my-1 flex flex-row gap-1"> {/* Raw file presence */}
{/* Issue number */} {isNil(rawFileDetails) && (
<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="icon custom-icon is-small has-text-danger mr-2">
<span className="pr-1 pt-1"> <img src="/src/client/assets/img/missing-file.svg" />
<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) && (
<div mt-1> <span className="icon custom-icon is-small has-text-danger">
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i> <img
</div> src="/src/client/assets/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)} )}
{/* ComicVine metadata presence */} {/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && ( {isComicBookMetadataAvailable && (
<span className="w-7 h-7"> <span className="icon custom-icon">
<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>
{/* Raw file presence */} </Card>
{isNil(rawFileDetails) && ( {/* metadata card */}
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2"> {!isNil(name) && (
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" /> <Card orientation="horizontal" hasDetails imageUrl={coverURL}>
</span> <dd className="is-size-9">
)} <dl>
</div> <span className="icon custom-icon">
</Card> <img src={`/src/client/assets/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
); );
}, },
)} )}
</div> </Masonry>
</div> </>
); );
}; };

View File

@@ -2,10 +2,15 @@ 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 Card from "../shared/Carda"; import Masonry from "react-masonry-css";
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();
@@ -14,48 +19,44 @@ export const VolumeGroups = (props): ReactElement => {
}; };
return ( return (
<section> <section className="volumes-container mt-4">
<Header <div className="content">
headerContent="Volumes" <a className="mb-1" onClick={navigateToVolumes}>
subHeaderContent="Based on ComicVine Volume information" <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-layer-group"></i> Volumes
link={"/volumes"} </span>
/> <span className="icon mt-1">
<div className="grid grid-cols-5 gap-6 mt-3"> <i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
{map(deduplicatedGroups, (data) => { {map(deduplicatedGroups, (data) => {
return ( return (
<div className="max-w-sm mx-auto" key={data._id}> <div className="stack" key={data._id}>
<Card <img src={data.volumes.image.small_url} />
orientation="vertical-2" <div className="content">
key={data._id} <div className="stack-title is-size-8">
imageUrl={data.volumes.image.small_url} <Link to={`/volume/details/${data._id}`}>
hasDetails {ellipsize(data.volumes.name, 18)}
> </Link>
<div className="py-3"> </div>
<div className="text-sm"> <div className="control">
<Link to={`/volume/details/${data._id}`}> <span className="tags has-addons">
{ellipsize(data.volumes.name, 48)} <span className="tag is-primary is-light">Issues</span>
</Link> <span className="tag">{data.volumes.count_of_issues}</span>
</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>
</Card> </div>
<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>
); );
})} })}
</div> </Masonry>
</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,17 +14,37 @@ type WantedComicsListProps = {
export const WantedComicsList = ({ export const WantedComicsList = ({
comics, comics,
}: WantedComicsListProps): ReactElement => { }: WantedComicsListProps): ReactElement => {
const navigate = useNavigate(); const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate();
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return ( return (
<> <>
<Header <div className="content mt-6">
headerContent="Wanted Comics" <a className="mb-1" onClick={navigateToWantedComics}>
subHeaderContent="Comics marked as wanted from various sources" <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-asterisk"></i> Wanted Comics
link={"/wanted"} </span>
/> <span className="icon mt-1">
<div className="grid grid-cols-5 gap-6 mt-3"> <i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">
Comics marked as wanted from various sources.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map( {map(
comics, comics,
({ ({
@@ -53,54 +73,42 @@ export const WantedComicsList = ({
return ( return (
<Card <Card
key={_id} key={_id}
orientation={"vertical-2"} orientation={"vertical"}
imageUrl={url} imageUrl={url}
hasDetails hasDetails
title={issueName ? titleElement : <span>No Name</span>} title={issueName ? titleElement : <span>No Name</span>}
> >
<div className="pb-1"> <div className="content is-flex is-flex-direction-row">
{/* 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),
) ? ( ) ? (
<div className="my-2"> <span className="tag is-warning">
<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"> detectIssueTypes(
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i> comicvine.volumeInformation.description,
</span> ).displayName
}
<span className="text-md text-slate-500 dark:text-slate-900"> </span>
{
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>
); );
}, },
)} )}
</div> </Masonry>
</> </>
); );
}; };

View File

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

View File

@@ -1,5 +1,13 @@
import React, { ReactElement, useEffect, useState } from "react"; import 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";
@@ -9,18 +17,18 @@ interface IDownloadsProps {
} }
export const Downloads = (props: IDownloadsProps): ReactElement => { export const Downloads = (props: IDownloadsProps): ReactElement => {
// const airDCPPConfiguration = useContext(AirDCPPSocketContext); const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { const {
airDCPPState: { settings, socket }, airDCPPState: { settings, socket },
} = airDCPPConfiguration; } = airDCPPConfiguration;
// const dispatch = useDispatch(); const dispatch = useDispatch();
// const airDCPPTransfers = useSelector( const airDCPPTransfers = useSelector(
// (state: RootState) => state.airdcpp.transfers, (state: RootState) => state.airdcpp.transfers,
// ); );
// const issueBundles = useSelector( const issueBundles = useSelector(
// (state: RootState) => state.airdcpp.issue_bundles, (state: RootState) => state.airdcpp.issue_bundles,
// ); );
const [bundles, setBundles] = useState([]); const [bundles, setBundles] = useState([]);
// Make the call to get all transfers from AirDC++ // Make the call to get all transfers from AirDC++
useEffect(() => { useEffect(() => {

View File

@@ -1,12 +1,14 @@
import React, { ReactElement, useCallback, useEffect } from "react"; import React, { ReactElement, useCallback, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
fetchComicBookMetadata,
getImportJobResultStatistics,
setQueueControl,
} from "../../actions/fileops.actions";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns"; import { format } from "date-fns";
import Loader from "react-loader-spinner"; import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
interface IProps { interface IProps {
matches?: unknown; matches?: unknown;
@@ -16,7 +18,10 @@ interface IProps {
} }
/** /**
* Component to facilitate the import of comics to the ThreeTwo library * Returns the average of two numbers.
*
* @remarks
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
* *
* @param x - The first input number * @param x - The first input number
* @param y - The second input number * @param y - The second input number
@@ -26,84 +31,62 @@ interface IProps {
*/ */
export const Import = (props: IProps): ReactElement => { export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient(); const dispatch = useDispatch();
const { importJobQueue, socketIOInstance } = useStore( const successfulImportJobCount = useSelector(
useShallow((state) => ({ (state: RootState) => state.fileOps.successfulJobCount,
importJobQueue: state.importJobQueue, );
socketIOInstance: state.socketIOInstance, const failedImportJobCount = useSelector(
})), (state: RootState) => state.fileOps.failedJobCount,
); );
const sessionId = localStorage.getItem("sessionId"); const lastQueueJob = useSelector(
const { mutate: initiateImport } = useMutation({ (state: RootState) => state.fileOps.lastQueueJob,
mutationFn: async () => );
await axios.request({ const libraryQueueImportStatus = useSelector(
url: `http://localhost:3000/api/library/newImport`, (state: RootState) => state.fileOps.LSQueueImportStatus,
method: "POST", );
data: { sessionId },
}),
});
const { data, isError, isLoading } = useQuery({ const allImportJobResults = useSelector(
queryKey: ["allImportJobResults"], (state: RootState) => state.fileOps.importJobStatistics,
queryFn: async () => );
await axios({
method: "GET", const initiateImport = useCallback(() => {
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics", if (typeof props.path !== "undefined") {
}), dispatch(fetchComicBookMetadata(props.path));
}); }
}, [dispatch]);
const toggleQueue = useCallback(
(queueAction: string, queueStatus: string) => {
dispatch(setQueueControl(queueAction, queueStatus));
},
[],
);
useEffect(() => {
dispatch(getImportJobResultStatistics());
}, []);
const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
(data) => console.log(data),
);
};
/**
* Method to render import job queue pause/resume controls on the UI
*
* @param status The `string` status (either `"pause"` or `"resume"`)
* @returns ReactElement A `<button/>` that toggles queue status
* @remarks Sets the global `importJobQueue.status` state upon toggling
*/
const renderQueueControls = (status: string): ReactElement | null => { const renderQueueControls = (status: string): ReactElement | null => {
switch (status) { switch (status) {
case "running": case "running":
return ( return (
<div> <div className="control">
<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-warning is-light"
onClick={() => { onClick={() => toggleQueue("pause", "paused")}
toggleQueue("pause", "paused");
importJobQueue.setStatus("paused");
}}
> >
<span className="text-md">Pause</span> <i className="fa-solid fa-pause mr-2"></i> Pause
<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> <div className="control">
<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-success is-light"
onClick={() => { onClick={() => toggleQueue("resume", "running")}
toggleQueue("resume", "running");
importJobQueue.setStatus("running");
}}
> >
<span className="text-md">Resume</span> <i className="fa-solid fa-play mr-2"></i> Resume
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
@@ -116,180 +99,136 @@ export const Import = (props: IProps): ReactElement => {
} }
}; };
return ( return (
<div> <div className="container">
<section> <section className="section is-small">
<header className="bg-slate-200 dark:bg-slate-500"> <h1 className="title">Import 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"> <article className="message is-dark">
<div className="sm:flex sm:items-center sm:justify-between"> <div className="message-body">
<div className="text-center sm:text-left"> <p className="mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl"> <span className="tag is-medium is-info is-light">
Import Import Comics
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p>
Importing will add comics identified from the mapped folder into
ThreeTwo's database.
</p>
<p>
Metadata from ComicInfo.xml, if present, will also be extracted.
</p>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div>
</article>
<div className="my-4">
{importJobQueue.status === "drained" ||
(importJobQueue.status === undefined && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="text-md">Start Import</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
))}
</div>
{/* Activity */}
{(importJobQueue.status === "running" ||
importJobQueue.status === "paused") && (
<>
<span className="flex items-center my-5 max-w-screen-lg">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Import Activity
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span> </span>
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center"> will add comics identified from the mapped folder into ThreeTwo's
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2"> database.
{/* Successful import counts */} </p>
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center"> <p>
<dd className="text-3xl text-green-600 md:text-5xl"> Metadata from ComicInfo.xml, if present, will also be extracted.
{importJobQueue.successfulJobCount} </p>
</dd> <p>
<dt className="text-lg font-medium text-gray-500"> This process could take a while, if you have a lot of comics, or
imported are importing over a network connection.
</dt> </p>
</div> </div>
{/* Failed job counts */} </article>
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center"> <p className="buttons">
<dd className="text-3xl text-red-600 md:text-5xl"> <button
{importJobQueue.failedJobCount} className={
</dd> libraryQueueImportStatus === "drained" ||
<dt className="text-lg font-medium text-gray-500"> libraryQueueImportStatus === undefined
failed ? "button is-medium"
</dt> : "button is-loading is-medium"
</div> }
onClick={initiateImport}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Start Import</span>
</button>
</p>
{libraryQueueImportStatus !== "drained" &&
!isUndefined(libraryQueueImportStatus) && (
<>
<table className="table">
<thead>
<tr>
<th>Completed Jobs</th>
<th>Failed Jobs</th>
<th>Queue Controls</th>
<th>Queue Status</th>
</tr>
</thead>
<div className="flex flex-col dark:text-slate-200 text-slate-400"> <tbody>
<dd>{renderQueueControls(importJobQueue.status)}</dd> <tr>
</div> <th>
</dl> {successfulImportJobCount > 0 && (
</div> <div className="box has-background-success-light has-text-centered">
<div className="flex"> <span className="is-size-2 has-text-weight-bold">
<span className="mt-2 dark:text-slate-200 text-slate-400"> {successfulImportJobCount}
Imported: <span>{importJobQueue.mostRecentImport}</span> </span>
</span> </div>
</div> )}
</th>
<td>
{failedImportJobCount > 0 && (
<div className="box has-background-danger has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{failedImportJobCount}
</span>
</div>
)}
</td>
<td>{renderQueueControls(libraryQueueImportStatus)}</td>
<td>
{libraryQueueImportStatus !== undefined ? (
<span className="tag is-warning">
{libraryQueueImportStatus}
</span>
) : null}
</td>
</tr>
</tbody>
</table>
Imported{" "}
<span className="has-text-weight-bold">{lastQueueJob}</span>
</> </>
)} )}
{/* Past imports */} {/* Past imports */}
{!isLoading && !isEmpty(data?.data) && (
<div className="max-w-screen-lg">
<span className="flex items-center mt-6">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Past Imports
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200"> <h3 className="subtitle is-4 mt-5">Past Imports</h3>
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md"> <table className="table">
<thead className="ltr:text-left rtl:text-right"> <thead>
<tr> <tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Time Started</th>
Time Started <th>Session Id</th>
</th> <th>Imported</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Failed</th>
Session Id </tr>
</th> </thead>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Imported
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Failed
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200"> <tbody>
{data?.data.map((jobResult, id) => { {allImportJobResults.map((jobResult, id) => {
return ( return (
<tr key={id}> <tr key={id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td>
{format( {format(
new Date(jobResult.earliestTimestamp), new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y", "EEEE, hh:mma, do LLLL Y",
)} )}
</td> </td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td>
<span className="tag is-warning"> <span className="tag is-warning">
{jobResult.sessionId} {jobResult.sessionId}
</span> </span>
</td> </td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300"> <td>
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700"> <span className="tag is-success">
<span className="h-5 w-6"> {jobResult.completedJobs}
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i> </span>
</span> </td>
<p className="whitespace-nowrap text-sm"> <td>
{jobResult.completedJobs} <span className="tag is-danger">
</p> {jobResult.failedJobs}
</span> </span>
</td> </td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300"> </tr>
<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> </tbody>
</span> </table>
<p className="whitespace-nowrap text-sm">
{jobResult.failedJobs}
</p>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</section> </section>
</div> </div>
); );

View File

@@ -1,18 +1,12 @@
import React, { useMemo, ReactElement, useState, useEffect } from "react"; import React, { useMemo, ReactElement, useCallback, 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 { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
/** /**
* Component that tabulates the contents of the user's ThreeTwo Library. * Component that tabulates the contents of the user's ThreeTwo Library.
@@ -22,60 +16,30 @@ import { format, fromUnixTime, parseISO } from "date-fns";
* <Library /> * <Library />
*/ */
export const Library = (): ReactElement => { export const Library = (): ReactElement => {
// Default page state const searchResults = useSelector(
// offset: 0 (state: RootState) => state.fileOps.libraryComics,
const [offset, setOffset] = useState(0); );
const [searchQuery, setSearchQuery] = useState({ const searchError = useSelector((state: RootState) => state.fileOps.librarySearchError);
query: {}, const dispatch = useDispatch();
pagination: { useEffect(() => {
size: 25, dispatch(
from: offset, searchIssue(
}, {
type: "all", query: {},
trigger: "libraryPage", },
}); {
const queryClient = useQueryClient(); pagination: {
size: 15,
from: 0,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
/** // programatically navigate to comic detail
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
query,
pagination,
type,
},
});
};
const searchIssues = (e) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToComicDetail = (row) => { const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`); navigate(`/comic/details/${row.original._id}`);
@@ -83,42 +47,45 @@ export const Library = (): ReactElement => {
const ComicInfoXML = (value) => { const ComicInfoXML = (value) => {
return value.data ? ( return value.data ? (
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max"> <div className="comicvine-metadata mt-3">
{/* Series Name */} <dl>
<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="tags has-addons is-size-7">
<span className="pr-1 pt-1"> <span className="tag">Series</span>
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i> <span className="tag is-warning is-light">
</span> {ellipsize(value.data.series[0], 25)}
<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>
{/* 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"> <dl>
<span className="pr-1 pt-1"> <div className="field is-grouped is-grouped-multiline">
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i> <div className="control">
</span> <span className="tags has-addons is-size-7 mt-2">
<span className="text-slate-900 dark:text-slate-900"> <span className="tag">Pages</span>
{!isNil(value.data.number) && ( <span className="tag is-info is-light has-text-weight-bold">
<span>{parseInt(value.data.number[0], 10)}</span> {value.data.pagecount[0]}
)} </span>
</span> </span>
</span> </div>
</div>
</dl> <div className="control">
<span className="tags has-addons is-size-7 mt-2">
<span className="tag">Issue</span>
{!isNil(value.data.number) && (
<span className="tag has-text-weight-bold is-success is-light">
{parseInt(value.data.number[0], 10)}
</span>
)}
</span>
</div>
</div>
</dl>
</div>
) : null; ) : null;
}; };
const WantedStatus = ({ value }) => {
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
};
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@@ -137,10 +104,14 @@ 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>
),
}, },
], ],
}, },
@@ -148,41 +119,28 @@ export const Library = (): ReactElement => {
header: "Additional Metadata", header: "Additional Metadata",
columns: [ columns: [
{ {
header: "Date of Import", header: "Publisher",
accessorKey: "_source.createdAt", accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (info) => { cell: (info) => {
return !isNil(info.getValue()) ? ( return (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900"> !isNil(info.getValue()) && (
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p> <h6 className="is-size-7 has-text-weight-bold">
{format(parseISO(info.getValue()), "h aaaa")} {info.getValue().publisher.name}
</div> </h6>
) : null; )
);
}, },
}, },
{ {
header: "Downloads", header: "Something",
accessorKey: "_source.acquisition", accessorKey: "_source.acquisition.source.wanted",
cell: (info) => ( cell: (info) => {
<div className="flex flex-col gap-2 ml-3 my-3"> !isUndefined(info.getValue()) ? (
<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"> <WantedStatus value={info.getValue().toString()} />
<span className="pr-1 pt-1"> ) : (
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i> "Nothing"
</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>
),
}, },
], ],
}, },
@@ -198,21 +156,23 @@ export const Library = (): ReactElement => {
* @returns void * @returns void
* *
**/ **/
const nextPage = (pageIndex: number, pageSize: number) => { const nextPage = useCallback((pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) { dispatch(
queryClient.invalidateQueries({ queryKey: ["comics"] }); searchIssue(
setSearchQuery({ {
query: {}, query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
}, },
type: "all", {
trigger: "libraryPage", pagination: {
}); size: pageSize,
// setOffset(pageSize * pageIndex + 1); from: pageSize * pageIndex + 1,
} },
}; type: "all",
trigger: "libraryPage",
},
),
);
}, []);
/** /**
* Pagination control that fetches the previous x (pageSize) items * Pagination control that fetches the previous x (pageSize) items
@@ -221,95 +181,77 @@ export const Library = (): ReactElement => {
* @param {number} pageSize * @param {number} pageSize
* @returns void * @returns void
**/ **/
const previousPage = (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 - (pageSize + 2); from = (pageIndex - 1) * pageSize + 2 - 17;
} else { } else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1); from = (pageIndex - 1) * pageSize + 2 - 16;
} }
queryClient.invalidateQueries({ queryKey: ["comics"] }); dispatch(
setSearchQuery({ searchIssue(
query: {}, {
pagination: { query: {},
size: 15, },
from, {
}, pagination: {
type: "all", size: pageSize,
trigger: "libraryPage", from,
}); },
// setOffset(from); type: "all",
}; trigger: "libraryPage",
},
),
);
}, []);
// ImportStatus.propTypes = { // ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired, // value: PropTypes.bool.isRequired,
// }; // };
return ( return (
<div> <section className="container">
<section> <div className="section">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="header-area">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Library</h1>
<div className="sm:flex sm:items-center sm:justify-between"> </div>
<div className="text-center sm:text-left"> {!isEmpty(searchResults) ? (
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Library
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
{!isUndefined(searchResults?.hits) ? (
<div> <div>
<div> <div className="library">
<T2Table <T2Table
totalPages={searchResults.hits.total.value} totalPages={searchResults.total.value}
columns={columns} columns={columns}
sourceData={searchResults?.hits.hits} sourceData={searchResults?.hits}
rowClickHandler={navigateToComicDetail} rowClickHandler={navigateToComicDetail}
paginationHandlers={{ paginationHandlers={{
nextPage, nextPage,
previousPage, previousPage,
}} }}
> />
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mx-auto max-w-screen-xl mt-5"> <div className="columns">
<article <div className="column is-two-thirds">
role="alert" <article className="message is-link">
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 className="message-body">
>
<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.
</p> </div>
</div> </article>
</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"> {!isUndefined(searchError.data) &&
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700"> JSON.stringify(
{!isUndefined(searchResults?.data?.meta?.body) ? ( searchError.data.meta.body.error.root_cause,
<p> null,
{JSON.stringify( 4,
searchResults?.data.meta.body.error.root_cause, )}
null,
4,
)}
</p>
) : null}
</pre> </pre>
</div> </div>
</div> </div>
)} )}
</section> </div>
</div> </section>
); );
}; };

View File

@@ -1,47 +1,63 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useCallback } 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 { useDispatch } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (props): ReactElement => { export const SearchBar = (): ReactElement => {
const { searchHandler } = props; const dispatch = useDispatch();
const handleSubmit = useCallback((e) => {
dispatch(
searchIssue(
{
query: {
volumeName: e.search,
},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
},
),
);
}, []);
return ( return (
<Form <div className="box">
onSubmit={searchHandler} <Form
initialValues={{}} onSubmit={handleSubmit}
render={({ handleSubmit, form, submitting, pristine, values }) => ( initialValues={{}}
<form onSubmit={handleSubmit}> render={({ handleSubmit, form, submitting, pristine, values }) => (
<Field name="search"> <form onSubmit={handleSubmit}>
{({ input, meta }) => { <div className="field is-grouped">
return ( <div className="control search is-expanded">
<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-medium"
<input placeholder="Type an issue/volume name"
{...input} />
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full" );
type="text" }}
id="search" </Field>
placeholder="Type an issue/volume name" </div>
/> <div className="control">
</div> <button className="button is-medium" type="submit">
Search
<button </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" </div>
type="submit" </div>
> </form>
Search )}
</button> />
</div>
); </div>
}}
</Field>
</form>
)}
/>
); );
}; };

View File

@@ -1,23 +1,25 @@
import React, { ReactElement, useEffect, useMemo } from "react"; import React, { ReactElement, useEffect, useMemo } from "react";
import T2Table from "../shared/T2Table"; import T2Table from "../shared/T2Table";
import { getWeeklyPullList } from "../../actions/comicinfo.actions"; import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { useDispatch, useSelector } from "react-redux";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { isNil } from "lodash"; import { isNil } from "lodash";
export const PullList = (): ReactElement => { export const PullList = (): ReactElement => {
// const pullListComics = useSelector( const pullListComics = useSelector(
// (state: RootState) => state.comicInfo.pullList, (state: RootState) => state.comicInfo.pullList,
// ); );
const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
// dispatch( dispatch(
// getWeeklyPullList({ getWeeklyPullList({
// startDate: "2023-7-28", startDate: "2023-7-28",
// pageSize: "15", pageSize: "15",
// currentPage: "1", currentPage: "1",
// }), }),
// ); );
}, []); }, []);
const nextPageHandler = () => {}; const nextPageHandler = () => {};
const previousPageHandler = () => {}; const previousPageHandler = () => {};

View File

@@ -1,18 +1,15 @@
import React, { useCallback, ReactElement, useState } from "react"; import React, { useCallback, ReactElement } 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 {}
@@ -20,223 +17,164 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = { const formData = {
search: "", search: "",
}; };
const queryClient = useQueryClient(); const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState(""); const getCVSearchResults = useCallback(
const [comicVineMetadata, setComicVineMetadata] = useState({}); (searchQuery) => {
const getCVSearchResults = (searchQuery) => { dispatch(
setSearchQuery(searchQuery.search); comicinfoAPICall({
}; callURIAction: "search",
callMethod: "GET",
const { callParams: {
data: comicVineSearchResults, api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
isLoading, query: searchQuery.search,
isSuccess, format: "json",
} = useQuery({ limit: "10",
queryFn: async () => offset: "0",
await axios({ field_list:
url: `${COMICVINE_SERVICE_URI}/search`, "id,name,deck,api_detail_url,image,description,volume,cover_date",
method: "GET", resources: "issue",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
}),
queryKey: ["comicvineSearchResults", searchQuery],
enabled: !isNil(searchQuery),
});
// add to library
const { data: additionResult } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata:
{ comicvine: comicVineMetadata?.comicData } || null,
acquisition: { source: { wanted: true, name: "comicvine" } },
}, },
}, }),
}), );
queryKey: ["additionResult"], },
enabled: !isNil(comicVineMetadata.comicData), [dispatch],
}); );
const addToLibrary = (sourceName: string, comicData) => const addToLibrary = useCallback(
setComicVineMetadata({ sourceName, comicData }); (sourceName: string, 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> <section className="container">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="section search">
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Search</h1>
<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}> <form onSubmit={handleSubmit} className="form columns search">
<div className="flex flex-row w-full"> <div className="column is-three-quarters search">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full"> <Field name="search">
<div className="w-10 text-gray-400"> {({ input, meta }) => {
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" /> return (
</div> <input
{...input}
<Field name="search"> className="input main-search-bar is-large"
{({ input, meta }) => { placeholder="Type an issue/volume name"
return ( />
<input );
{...input} }}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full" </Field>
placeholder="Type an issue/volume name" </div>
/> <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>
)} )}
/> />
</div> {!isNil(comicVineSearchResults.results) &&
{isLoading && <>Loading kaka...</>} !isEmpty(comicVineSearchResults.results) ? (
{!isNil(comicVineSearchResults?.data.results) && <div className="columns is-multiline">
!isEmpty(comicVineSearchResults?.data.results) ? ( {comicVineSearchResults.results.map((result) => {
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> return (
{comicVineSearchResults.data.results.map((result) => { <div
return isSuccess ? ( key={result.id}
<div key={result.id} className="mb-5"> className="comicvine-metadata column is-10 mb-3"
<div className="flex flex-row"> >
<div className="mr-5"> <div className="columns">
<Card <div className="column is-one-quarter">
key={result.id} <Card
orientation={"cover-only"} key={result.id}
imageUrl={result.image.small_url} orientation={"vertical"}
hasDetails={false} imageUrl={result.image.small_url}
/> title={result.name}
</div> hasDetails={false}
<div className="column"> ></Card>
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div> </div>
<div className="field is-grouped mt-1"> <div className="column">
<div className="control"> <div className="is-size-3">
<div className="tags has-addons"> {!isEmpty(result.name) ? (
<span className="tag is-light">Cover date</span> result.name
<span className="tag is-info is-light"> ) : (
{dayjs(result.cover_date).format("MMM D, YYYY")} <span className="is-size-3">No Name</span>
</span> )}
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning">
{result.id}
</span>
</div>
</div> </div>
</div> </div>
<div className="control"> <a href={result.api_detail_url}>
<div className="tags has-addons"> {result.api_detail_url}
<span className="tag is-warning">{result.id}</span> </a>
</div> <p>
</div> {ellipsize(
</div> convert(result.description, {
baseElements: {
<a href={result.api_detail_url}> selectors: ["p"],
{result.api_detail_url} },
</a> }),
<p> 320,
{ellipsize( )}
convert(result.description, { </p>
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<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-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" className="button is-success is-light is-outlined mt-2"
onClick={() => addToLibrary("comicvine", result)} onClick={() => addToLibrary("comicvine", result)}
> >
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "} <i className="fa-solid fa-plus mr-2"></i> Want
Mark as Wanted
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> );
) : ( })}
<div>Loading</div> </div>
); ) : (
})} <article className="message is-dark is-half">
</div> <div className="message-body">
) : ( <p className="mb-2">
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <span className="tag is-medium is-info is-light">
<article Search the ComicVine database
role="alert" </span>
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

@@ -1,65 +1,39 @@
import React, { ReactElement, useEffect, useState, useContext } from "react"; import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { useDispatch } from "react-redux";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select"; import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { saveSettings } from "../../../actions/settings.actions";
import { useStore } from "../../../store"; import { AirDCPPSocketContext } from "../../../context/AirDCPPSocket";
import axios from "axios";
export const AirDCPPHubsForm = (): ReactElement => { export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
const queryClient = useQueryClient(); const dispatch = useDispatch();
const [hubList, setHubList] = useState([]);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { const {
airDCPPSocketInstance, airDCPPState: { settings, socket },
airDCPPClientConfiguration, } = airDCPPConfiguration;
airDCPPSessionInformation,
} = useStore((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
}));
const { useEffect(() => {
data: settings, (async () => {
isLoading, if (!isEmpty(settings)) {
isError, const hubs = await socket.get(`hubs`);
} = useQuery({ const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
queryKey: ["settings"], value: hub_url,
queryFn: async () => label: identity.name,
await axios({ }));
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET", setHubList(hubSelectionOptions);
}), }
}); })();
}, []);
const onSubmit = (values) => {
if (!isUndefined(values.hubs)) {
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
}
};
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
let hubList = {};
if (!isNil(hubs)) {
hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: {
settingsPayload: values,
settingsObjectId: settings?.data._id,
settingsKey: "directConnect",
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
const validate = async () => {}; const validate = async () => {};
const SelectAdapter = ({ input, ...rest }) => { const SelectAdapter = ({ input, ...rest }) => {
@@ -68,70 +42,53 @@ export const AirDCPPHubsForm = (): ReactElement => {
return ( return (
<> <>
{!isEmpty(hubList) && !isUndefined(hubs) ? ( <Form
<Form onSubmit={onSubmit}
onSubmit={mutate} validate={validate}
validate={validate} render={({ handleSubmit }) => (
render={({ handleSubmit }) => ( <form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}> <div>
<div> <h3 className="title">Hubs</h3>
<h3 className="title">Hubs</h3> <h6 className="subtitle has-text-grey-light">
<h6 className="subtitle has-text-grey-light"> Select the hubs you want to perform searches against.
Select the hubs you want to perform searches against. </h6>
</h6>
</div>
<div className="field">
<label className="label">AirDC++ Host</label>
<div className="control">
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
</div>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
)}
/>
) : (
<>
<article className="message">
<div className="message-body">
No configured hubs detected in AirDC++. <br />
Configure to a hub in AirDC++ and then select a default hub here.
</div> </div>
</article> <div className="field">
</> <label className="label">AirDC++ Host</label>
)} <div className="control">
{!isEmpty(settings?.data.directConnect?.client.hubs) ? ( <Field
<> name="hubs"
<div className="mt-4"> component={SelectAdapter}
<article className="message is-warning"> className="basic-multi-select"
<div className="message-body is-size-6 is-family-secondary"> placeholder="Select Hubs to Search Against"
Your selection in the dropdown <strong>will replace</strong> the options={hubList}
existing selection. />
</div> </div>
</article> </div>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
)}
/>
<div className="mt-4">
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div> </div>
<div className="box mt-3"> </article>
<h6>Default Hub For Searches:</h6> </div>
{settings?.data.directConnect?.client.hubs.map( <div className="box mt-3">
({ value, label }) => ( <h6>Selected hubs</h6>
<div key={value}> {settings.directConnect.client.hubs.map(({ value, label }) => (
<div>{label}</div> <div key={value}>
<span className="is-size-7">{value}</span> <div>{label}</div>
</div> <span className="is-size-7">{value}</span>
),
)}
</div> </div>
</> ))}
) : null} </div>
</> </>
); );
}; };

View File

@@ -3,36 +3,29 @@ import React, { ReactElement } from "react";
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => { export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
const { settings } = settingsObject; const { settings } = settingsObject;
return ( return (
<div> <div className="mt-4 is-clearfix">
<span className="flex items-center mt-10 mb-4"> <div className="card">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5"> <div className="card-content">
AirDC++ Client Information <span className="tag is-pulled-right is-primary">Connected</span>
</span> <div className="content is-size-7">
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span> <dl>
</span> <dt>{settings._id}</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>Client version: {settings.system_info.client_version}</dt>
<span className="inline-flex justify-center rounded-full bg-emerald-100 mb-4 px-2 py-0.5 text-emerald-700"> <dt>Hostname: {settings.system_info.hostname}</dt>
<span className="h-5 w-6"> <dt>Platform: {settings.system_info.platform}</dt>
<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:{" "}
{JSON.stringify(settings.user.permissions, undefined, 2)} <pre>
</dt> {JSON.stringify(settings.user.permissions, undefined, 2)}
</dl> </pre>
</p> </dt>
</dl>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -1,62 +0,0 @@
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,10 +1,9 @@
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, QueryClient } from "@tanstack/react-query"; import { useQuery, useMutation } 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"],
@@ -14,9 +13,18 @@ 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"],
@@ -25,7 +33,9 @@ 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) =>
@@ -34,11 +44,6 @@ 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)
@@ -56,24 +61,9 @@ export const QbittorrentConnectionForm = (): ReactElement => {
submitHandler={mutate} submitHandler={mutate}
/> />
<span className="flex items-center mt-10 mb-4"> <pre className="mt-5">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5"> {JSON.stringify(qbittorrentClientInfo?.data, null, 4)}
qBittorrent Client Information </pre>
</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,7 +3,6 @@ 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";
@@ -12,15 +11,10 @@ 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",
content: ( content: <div key="adc-hubs">{/* <AirDCPPHubsForm /> */}</div>,
<div key="adc-hubs">
<AirDCPPHubsForm />
</div>
),
}, },
{ {
id: "adc-connection", id: "adc-connection",
@@ -38,116 +32,81 @@ export const Settings = (props: ISettingsProps): ReactElement => {
</div> </div>
), ),
}, },
{
id: "prwlr-connection",
content: (
<>
<ProwlarrSettingsForm />
</>
),
},
{ {
id: "core-service", id: "core-service",
content: <>a</>, content: <>a</>,
}, },
{ {
id: "flushdb", id: "flushdb",
content: ( content: <div key="flushdb">{/* <SystemSettingsForm /> */}</div>,
<div key="flushdb">
<SystemSettingsForm />
</div>
),
}, },
]; ];
return ( return (
<div> <section className="container">
<section> <div className="columns">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="section column is-one-quarter">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Settings</h1>
<div className="sm:flex sm:items-center sm:justify-between"> <aside className="menu">
<div className="text-center sm:text-left"> {map(settingsObject, (settingObject, idx) => {
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl"> return (
Settings <div key={idx}>
</h1> <p className="menu-label">{settingObject.category}</p>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul className="menu-list" key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx}>
<a
className={
item.id.toString() === active ? "is-active" : ""
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul>
{map(item.children, (item, idx) => (
<li key={item.id}>
<a
className={
item.id.toString() === active
? "is-active"
: ""
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
</div>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white"> {/* content for settings */}
Import comics into the ThreeTwo library. <div className="section column is-half mt-6">
</p> <div className="content">
</div> {map(settingsContent, ({ id, content }) =>
</div> active === id ? content : null,
</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>
</section> </div>
</div> </section>
); );
}; };

View File

@@ -1,16 +1,15 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useCallback } from "react";
import { useMutation } from "@tanstack/react-query"; import { flushDb } from "../../../actions/settings.actions";
import axios from "axios"; import { useDispatch, useSelector } from "react-redux";
export const SystemSettingsForm = (): ReactElement => { export const SystemSettingsForm = (): ReactElement => {
const { mutate: flushDb, isLoading } = useMutation({ const dispatch = useDispatch();
mutationFn: async () => { const isSettingsCallInProgress = useSelector(
await axios({ (state: RootState) => state.settings.inProgress,
url: `http://localhost:3000/api/library/flushDb`, );
method: "POST", const flushDatabase = useCallback(() => {
}); dispatch(flushDb());
}, }, []);
});
return ( return (
<div className="is-clearfix"> <div className="is-clearfix">
@@ -49,9 +48,11 @@ export const SystemSettingsForm = (): ReactElement => {
<button <button
className={ className={
isLoading ? "button is-danger is-loading" : "button is-danger" isSettingsCallInProgress
? "button is-danger is-loading"
: "button is-danger"
} }
onClick={() => flushDb()} onClick={flushDatabase}
> >
<span className="icon"> <span className="icon">
<i className="fas fa-eraser"></i> <i className="fas fa-eraser"></i>

View File

@@ -1,27 +1,22 @@
import React, { ReactElement, useEffect, useMemo } from "react"; import React, { ReactElement, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions"; import { searchIssue } from "../../actions/fileops.actions";
import Card from "../shared/Carda"; 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 { useQuery } from "@tanstack/react-query"; import { isUndefined } from "lodash";
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);
const { const dispatch = useDispatch();
data: volumes, useEffect(() => {
isSuccess, dispatch(
isError, searchIssue(
isLoading, {
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {}, query: {},
},
{
pagination: { pagination: {
size: 25, size: 25,
from: 0, from: 0,
@@ -29,57 +24,106 @@ export const Volumes = (props): ReactElement => {
type: "volumes", type: "volumes",
trigger: "volumesPage", 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): any => { cell: (row) => {
const foo = row.getValue(); const foo = row.getValue();
return ( return (
<div className="flex flex-row gap-3 mt-5"> <div className="columns">
<Card <div className="column">
imageUrl={ <div className="comic-detail issue-metadata">
foo.sourcedMetadata.comicvine.volumeInformation.image <dl>
.small_url <dd>
} <div className="columns mt-2">
orientation={"cover-only"} <div className="">
hasDetails={false} <Card
/> imageUrl={
<div className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg h-fit"> foo.sourcedMetadata.comicvine.volumeInformation
<span className="text-xl mb-1"> .image.thumb_url
{foo.sourcedMetadata.comicvine.volumeInformation.name} }
</span> orientation={"vertical"}
<p> hasDetails={false}
{ellipsize( // cardContainerStyle={{ maxWidth: 200 }}
convert( />
foo.sourcedMetadata.comicvine.volumeInformation </div>
.description, <div className="column">
{ <dl>
baseElements: { <dt>
selectors: ["p"], <h6 className="name has-text-weight-medium mb-1">
}, {
}, foo.sourcedMetadata.comicvine
), .volumeInformation.name
120, }
)} </h6>
</p> </dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
</div> </div>
</div> </div>
); );
}, },
}, },
{ {
header: "Other Details", header: "Download Status",
columns: [ columns: [
{ {
header: "Downloads", header: "Files",
accessorKey: "_source.acquisition.directconnect", accessorKey: "_source.acquisition.directconnect",
align: "right", align: "right",
cell: (props) => { cell: (props) => {
@@ -100,34 +144,12 @@ export const Volumes = (props): ReactElement => {
}, },
}, },
{ {
header: "Publisher", header: "Type",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation", id: "Air",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
}, },
{ {
header: "Issue Count", header: "Type",
accessorKey: id: "dcc",
"_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>
);
},
}, },
], ],
}, },
@@ -135,29 +157,17 @@ export const Volumes = (props): ReactElement => {
[], [],
); );
return ( return (
<div> <section className="container">
<section className=""> <div className="section">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="header-area">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Volumes</h1>
<div className="sm:flex sm:items-center sm:justify-between"> </div>
<div className="text-center sm:text-left"> {!isUndefined(volumes.hits) && (
<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?.data.hits.hits} sourceData={volumes?.hits}
totalPages={volumes?.data.hits.hits.length} totalPages={volumes.hits.length}
paginationHandlers={{ paginationHandlers={{
nextPage: () => {}, nextPage: () => {},
previousPage: () => {}, previousPage: () => {},
@@ -166,13 +176,9 @@ export const Volumes = (props): ReactElement => {
/> />
</div> </div>
</div> </div>
) : null} )}
{isError ? ( </div>
<div>An error was encountered while retrieving volumes</div> </section>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
); );
}; };

View File

@@ -1,37 +1,34 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react"; import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar"; import 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 { const wantedComics = useSelector(
data: wantedComics, (state: RootState) => state.fileOps.wantedComics,
isSuccess, );
isFetched, const dispatch = useDispatch();
isError, useEffect(() => {
isLoading, dispatch(
} = useQuery({ searchIssue(
queryFn: async () => {
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {}, query: {},
},
{
pagination: { pagination: {
size: 25, size: 25,
from: 0, from: 0,
}, },
type: "wanted", type: "wanted",
trigger: "wantedComicsPage", trigger: "wantedComicsPage"
}, },
}), ),
queryKey: ["wantedComics"], );
enabled: true, }, []);
});
const columnData = [ const columnData = [
{ {
header: "Comic Information", header: "Comic Information",
@@ -40,12 +37,8 @@ export const WantedComics = (props): ReactElement => {
header: "Details", header: "Details",
id: "comicDetails", id: "comicDetails",
minWidth: 350, minWidth: 350,
accessorFn: (data) => data, accessorFn: data => data,
cell: (value) => { cell: (value) => <MetadataPanel data={value.getValue()} />,
console.log("ASDASd", value);
const row = value.getValue()._source;
return row && <MetadataPanel data={row} />;
},
}, },
], ],
}, },
@@ -54,12 +47,10 @@ 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 { directconnect: { downloads } } = props.getValue();
const {
directconnect: { downloads },
} = props.getValue();
return ( return (
<div <div
style={{ style={{
@@ -69,7 +60,9 @@ export const WantedComics = (props): ReactElement => {
}} }}
> >
{downloads.length > 0 ? ( {downloads.length > 0 ? (
<span className="tag is-warning">{downloads.length}</span> <span className="tag is-warning">
{downloads.length}
</span>
) : null} ) : null}
</div> </div>
); );
@@ -78,18 +71,12 @@ export const WantedComics = (props): ReactElement => {
{ {
header: "Download Details", header: "Download Details",
id: "downloadDetails", id: "downloadDetails",
accessorKey: "_source.acquisition", accessorKey: "acquisition",
cell: (data) => ( cell: data => <ol>
<ol> {data.getValue().directconnect.downloads.map((download, idx) => {
{data.getValue().directconnect.downloads.map((download, idx) => { return <li className="is-size-7" key={idx}>{download.name}</li>;
return ( })}
<li className="is-size-7" key={idx}> </ol>
{download.name}
</li>
);
})}
</ol>
),
}, },
{ {
header: "Type", header: "Type",
@@ -105,25 +92,26 @@ export const WantedComics = (props): ReactElement => {
* @param {number} pageIndex * @param {number} pageIndex
* @param {number} pageSize * @param {number} pageSize
* @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
@@ -132,71 +120,55 @@ 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 (
<div className=""> <section className="container">
<section className=""> <div className="section">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="header-area">
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Wanted Comics</h1>
<div className="sm:flex sm:items-center sm:justify-between"> </div>
<div className="text-center sm:text-left"> {!isEmpty(wantedComics) && (
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
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?.data.hits.hits} sourceData={wantedComics}
totalPages={wantedComics?.data.hits.hits.length} totalPages={wantedComics.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} )}
{isLoading ? <div>Loading...</div> : null} </div>
{isError ? ( </section>
<div>An error occurred while retrieving the pull list.</div>
) : null}
</section>
</div>
); );
}; };

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
export const Canvas = ({ data }) => { export const Canvas = (data) => {
const { colorHistogramData } = data; const { colorHistogramData } = data.data;
console.log(data);
const width = 559; const width = 559;
const height = 200; const height = 200;
const pixelRatio = window.devicePixelRatio; const pixelRatio = window.devicePixelRatio;
@@ -9,11 +10,7 @@ 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;
@@ -49,24 +46,18 @@ 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,87 +80,6 @@ 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,141 +14,91 @@ export const ConnectionForm = ({
onSubmit={submitHandler} onSubmit={submitHandler}
initialValues={initialData} initialValues={initialData}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} className="mt-10"> <form onSubmit={handleSubmit}>
<h2 className="text-xl">{formHeading}</h2> <h2>{formHeading}</h2>
<article <label className="label">Hostname</label>
role="alert" <div className="field has-addons">
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" <p className="control">
> <span className="select">
<div> <Field name="protocol" component="select">
<p>Configure your AirDC++ client connection here.</p> <option>Protocol</option>
<p> <option value="http">http://</option>
Note that you need an instance of AirDC++ already running to <option value="https">https://</option>
use this form to connect to it. </Field>
</p> </span>
<p> </p>
See{" "} <div className="control is-expanded">
<a <Field name="hostname" validate={hostNameValidator}>
className="underline" {({ input, meta }) => (
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="text-sm text-red-400 px-2"> <span className="is-size-7 has-text-danger">
{meta.error} {meta.error}
</span> </span>
)} )}
</div> </div>
</div> )}
)} </Field>
</Field> </div>
<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="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" className="input"
placeholder="Port" placeholder="port"
/> />
</div> </p>
</div> </div>
<div className="field">
<div className="flex flex-row mt-5"> <label className="label">Credentials</label>
<div> <div className="field-body">
<label className="block py-1">Username</label> <div className="field">
<div className="relative"> <p className="control is-expanded has-icons-left">
<Field <Field
name="username" name="username"
component="input" component="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" className="input"
placeholder="Username" placeholder="Username"
/> />
<span className="absolute h-6 w-6 left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none"> <span className="icon is-small is-left">
<i className="icon-[solar--user-bold-duotone] h-6 w-6 dark:text-slate-200" /> <i className="fa-solid fa-user-ninja"></i>
</span> </span>
</p>
</div> </div>
</div> <div className="field">
<div> <p className="control is-expanded has-icons-left has-icons-right">
<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="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" className="input"
placeholder="Password" placeholder="Password"
/> />
<span className="absolute left-2 top-2 inset-y-0 flex items-center px-0 pointer-events-none h-6 w-6"> <span className="icon is-small is-left">
<i className="icon-[solar--lock-password-bold-duotone] h-6 w-6 dark:text-slate-200" /> <i className="fa-solid fa-lock"></i>
</span> </span>
</div> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-row gap-2"> <div className="field is-grouped">
<button <p className="control">
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" <button type="submit" className="button is-primary">
type="submit"
>
<span className="text-md">
{!isEmpty(initialData) ? "Update" : "Save"} {!isEmpty(initialData) ? "Update" : "Save"}
</span> </button>
<span className="w-6 h-6"> </p>
<i className="h-6 w-6 icon-[solar--diskette-bold-duotone]"></i>
</span>
</button>
<button <p className="control">
type="submit" <button type="submit" className="button is-danger">
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" {!isEmpty(initialData) && "Delete"}
> </button>
{!isEmpty(initialData) && "Delete"} </p>
</button>
</div> </div>
</form> </form>
)} )}

View File

@@ -1,127 +0,0 @@
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 control-palette"> <div className="box p-2 pl-3 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,6 +8,7 @@ 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,5 +0,0 @@
import React from "react";
export const ErrorPage = () => {
return <>Error has been encountered.</>;
};

View File

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

@@ -1,46 +1,15 @@
import React from "react"; import React, { useContext } from "react";
import { SearchBar } from "../GlobalSearchBar/SearchBar"; import { SearchBar } from "../GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick"; import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns"; import { format, fromUnixTime } from "date-fns";
import { useStore } from "../../store/index";
import { useShallow } from "zustand/react/shallow";
const Navbar: React.FunctionComponent = (props) => { const Navbar: React.FunctionComponent = (props) => {
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPDownloadTick,
importJobQueue,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
importJobQueue: state.importJobQueue,
})),
);
// const downloadProgressTick = useSelector(
// (state: RootState) => state.airdcpp.downloadProgressData,
// );
//
// const airDCPPSocketConnectionStatus = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
// );
// const airDCPPSessionInfo = useSelector(
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
// );
// const socketDisconnectionReason = useSelector(
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
// );
return ( return (
<nav className="navbar is-fixed-top"> <nav className="navbar is-fixed-top">
<div className="navbar-brand"> <div className="navbar-brand">
<Link to="/dashboard" className="navbar-item"> <Link to="/" className="navbar-item">
<img <img
src="/src/client/assets/img/threetwo.svg" src="/src/client/assets/img/threetwo.svg"
alt="ThreeTwo! A comic book curator" alt="ThreeTwo! A comic book curator"
@@ -86,8 +55,6 @@ const Navbar: React.FunctionComponent = (props) => {
Downloads Downloads
</Link> </Link>
{/* <SearchBar /> */}
<Link to="/search" className="navbar-item"> <Link to="/search" className="navbar-item">
Search ComicVine Search ComicVine
</Link> </Link>
@@ -96,102 +63,7 @@ const Navbar: React.FunctionComponent = (props) => {
<div className="navbar-end"> <div className="navbar-end">
<a className="navbar-item is-hidden-desktop-only"></a> <a className="navbar-item is-hidden-desktop-only"></a>
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i>
{!isEmpty(airDCPPDownloadTick) && (
<div className="pulsating-circle"></div>
)}
</a>
{!isEmpty(airDCPPDownloadTick) ? (
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<DownloadProgressTick data={airDCPPDownloadTick} />
</a>
</div>
) : null}
</div>
{!isUndefined(importJobQueue.status) &&
location.hash !== "#/import" ? (
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
</a>
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<ul>
{importJobQueue.successfulJobCount > 0 ? (
<li className="mb-2">
<span className="tag is-success mr-2">
{importJobQueue.successfulJobCount}
</span>
imported.
</li>
) : null}
{importJobQueue.failedJobCount > 0 ? (
<li>
<span className="tag is-danger mr-2">
{importJobQueue.failedJobCount}
</span>
failed to import.
</li>
) : null}
</ul>
</a>
</div>
</div>
) : null}
{/* AirDC++ socket connection status */} {/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{!isUndefined(airDCPPSessionInformation.user) ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
{/* AirDC++ Session Information */}
<p>
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(
airDCPPSessionInformation?.user.last_login,
),
"dd MMMM, yyyy",
)}
</span>
</p>
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInformation.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.session_id}
</span>
</p>
</div>
</>
) : (
<>
<a className="navbar-link is-arrowless has-text-danger">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
</div>
</>
)}
</div>
<div className="navbar-item has-dropdown is-hoverable is-mega"> <div className="navbar-item has-dropdown is-hoverable is-mega">
<div className="navbar-link flex">Blog</div> <div className="navbar-link flex">Blog</div>

View File

@@ -1,129 +0,0 @@
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,5 +1,7 @@
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,
@@ -10,30 +12,26 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
export const T2Table = (tableOptions): ReactElement => { export const T2Table = (tableOptions): ReactElement => {
const { const { sourceData, columns, paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler } =
sourceData, tableOptions;
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
} = tableOptions;
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({ const [{ pageIndex, pageSize }, setPagination] =
pageIndex: 1, useState<PaginationState>({
pageSize: 15, pageIndex: 1,
}); pageSize: 15,
});
const pagination = useMemo( const pagination = useMemo(
() => ({ () => ({
pageIndex, pageIndex,
pageSize, pageSize,
}), }),
[pageIndex, pageSize], [pageIndex, pageSize]
); );
/** /**
* Pagination control to move forward one page * Pagination control to move forward one page
* @returns void * @returns void
*/ */
const goToNextPage = () => { const goToNextPage = () => {
setPagination({ setPagination({
@@ -41,10 +39,10 @@ export const T2Table = (tableOptions): ReactElement => {
pageSize, pageSize,
}); });
nextPage(pageIndex, pageSize); nextPage(pageIndex, pageSize);
}; }
/** /**
* Pagination control to move backward one page * Pagination control to move backward one page
* @returns void * @returns void
**/ **/
const goToPreviousPage = () => { const goToPreviousPage = () => {
@@ -53,7 +51,7 @@ export const T2Table = (tableOptions): ReactElement => {
pageSize, pageSize,
}); });
previousPage(pageIndex, pageSize); previousPage(pageIndex, pageSize);
}; }
const table = useReactTable({ const table = useReactTable({
data: sourceData, data: sourceData,
@@ -68,51 +66,62 @@ export const T2Table = (tableOptions): ReactElement => {
}); });
return ( return (
<div className="container max-w-fit mx-14"> <>
<div> <div className="columns table-controls">
<div className="flex flex-row gap-2 justify-between mt-7"> {/* Search bar */}
{/* Search bar */} <div className="column is-half">
{tableOptions.children} <SearchBar />
{/* pagination controls */} </div>
<div> {/* pagination controls */}
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} <nav className="pagination columns">
<p>{totalPages} comics in all</p> <div className="mr-4 has-text-weight-semibold has-text-left">
{/* Prev/Next buttons */} <p className="is-size-5">Page {pageIndex} of {Math.ceil(totalPages / pageSize)}</p>
<div className="inline-flex flex-row mt-4 mb-4"> {/* <p>{totalPages} comics in all</p> */}
<button </div>
onClick={() => goToPreviousPage()} <div className="field has-addons">
disabled={pageIndex === 1} <div className="control">
className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2" <div className="button" onClick={() => goToPreviousPage()}> <i className="fas fa-chevron-left"></i></div>
> </div>
<i className="icon-[solar--arrow-left-linear] h-6 w-6"></i> <div className="control">
</button> <div className="button" onClick={() => goToNextPage()}> <i className="fas fa-chevron-right"></i> </div>
<button </div>
className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2"
onClick={() => goToNextPage()} <div className="field has-addons ml-5">
disabled={pageIndex > Math.floor(totalPages / pageSize)} <p className="control">
> <button className="button">
<i className="icon-[solar--arrow-right-linear] h-6 w-6"></i> <span className="icon is-small">
</button> <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> </div>
</div> </nav>
</div> </div>
<table className="table-auto overflow-auto"> <table className="table is-hoverable">
<thead className="sticky top-0 bg-slate-200 dark:bg-slate-500"> <thead>
{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 <th
key={header.id} key={header.id}
colSpan={header.colSpan} colSpan={header.colSpan}
className="px-3 py-3"
> >
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext()
)} )}
</th> </th>
))} ))}
</tr> </tr>
@@ -122,23 +131,21 @@ export const T2Table = (tableOptions): ReactElement => {
<tbody> <tbody>
{table.getRowModel().rows.map((row, idx) => { {table.getRowModel().rows.map((row, idx) => {
return ( return (
<tr key={row.id} onClick={() => rowClickHandler(row)}> <tr
{row.getVisibleCells().map((cell) => { key={row.id}
return ( onClick={() => rowClickHandler(row)}
<td key={cell.id} className="align-top"> >
{flexRender( {row.getVisibleCells().map(cell => (
cell.column.columnDef.cell, <td key={cell.id}>
cell.getContext(), {flexRender(cell.column.columnDef.cell, cell.getContext())}
)} </td>
</td> ))}
);
})}
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </>
); );
}; };
@@ -151,6 +158,5 @@ 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

@@ -0,0 +1,48 @@
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,6 +8,7 @@ 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",
@@ -90,17 +91,3 @@ 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

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

View File

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

View File

@@ -0,0 +1,108 @@
import { isEmpty, isUndefined } from "lodash";
import React, { createContext, useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useStore } from "../store/index";
import AirDCPPSocket from "../services/DcppSearchService";
import axios from "axios";
const AirDCPPSocketContextProvider = ({ children }) => {
const { getState, setState } = useStore;
// setter for settings for use in the context consumer
const setSettings = (settingsObject) => {
persistSettings({
...airDCPPState,
airDCPPState: {
settings: settingsObject,
socket: {},
socketConnectionInformation: {},
},
});
};
// Initial state for AirDC++ configuration
const initState = {
airDCPPState: {
settings: {},
socket: {},
socketConnectionInformation: {},
},
setSettings: setSettings,
};
const [airDCPPState, persistSettings] = useState(initState);
// 1. get settings from mongo
const { data, isLoading, isError } = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
const directConnectConfiguration = data?.data.directConnect.client.host;
// 2. If available, init AirDC++ Socket with those settings
useEffect(() => {
if (!isEmpty(directConnectConfiguration)) {
initializeAirDCPPSocket(directConnectConfiguration);
}
}, [directConnectConfiguration]);
// Method to init AirDC++ Socket with supplied settings
const initializeAirDCPPSocket = async (configuration) => {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// Attempt connection
const socketConnectionInformation =
await initializedAirDCPPSocket.connect();
// update the state with the new socket connection information
persistSettings({
...airDCPPState,
airDCPPState: {
settings: configuration,
socket: initializedAirDCPPSocket,
socketConnectionInformation,
},
});
};
console.log("connected?", getState());
// the Provider gives access to the context to its children
return (
<AirDCPPSocketContext.Provider value={airDCPPState}>
{children}
</AirDCPPSocketContext.Provider>
);
};
const AirDCPPSocketContext = createContext({
airDCPPState: {},
saveSettings: () => {},
});
export { AirDCPPSocketContext, AirDCPPSocketContextProvider };

View File

@@ -1,16 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -4,17 +4,10 @@ import { createRoot } from "react-dom/client";
import App from "./components/App"; import App from "./components/App";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Settings from "./components/Settings/Settings"; import Settings from "./components/Settings/Settings";
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 Import from "./components/Import/Import"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Dashboard from "./components/Dashboard/Dashboard";
import Search from "./components/Search/Search";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
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,29 +15,16 @@ const router = createBrowserRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <ErrorPage />, },
children: [ {
{ path: "/", element: <Dashboard /> }, path: "/settings",
{ path: "dashboard", element: <Dashboard /> }, element: <Settings />,
{ path: "settings", element: <Settings /> },
{
path: "library",
element: <TabulatedContentContainer category="library" />,
},
{
path: "comic/details/:comicObjectId",
element: <ComicDetailContainer />,
},
{ path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> },
{ path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> },
],
}, },
]); ]);
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>, </QueryClientProvider>,
); );

View File

@@ -1,252 +1,90 @@
import { create } from "zustand"; import { create } from "zustand";
import { isNil } from "lodash"; import { isEmpty } from "lodash";
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints";
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:
* 1. The zustand-based global client state
* 2. socket.io client
* 3. AirDC++ websocket connection
*/
export const useStore = create((set, get) => ({ export const useStore = create((set, get) => ({
// AirDC++ state // AirDC++ state
airDCPPSocketInstance: {},
airDCPPSocketConnected: false, airDCPPSocketConnected: false,
airDCPPDisconnectionInfo: {}, airDCPPDisconnectionInfo: {},
airDCPPClientConfiguration: {}, airDCPPClientConfiguration: {},
airDCPPSessionInformation: {}, airDCPPSocketConnectionInformation: {},
setAirDCPPSocketConnectionStatus: () => setAirDCPPSocketConnectionStatus: () =>
set((value) => ({ set((value) => ({
airDCPPSocketConnected: value, airDCPPSocketConnected: value,
})), })),
airDCPPDownloadTick: {}, getAirDCPPConnectionStatus: () => {
airDCPPTransfers: {}, const airDCPPSocketConnectionStatus = get().airDCPPSocketConnected;
},
// Socket.io state // Socket.io state
socketIOInstance: {},
// ComicVine Scraping status
comicvine: {
scrapingStatus: "",
},
// Import job queue and associated statuses
importJobQueue: {
successfulJobCount: 0,
failedJobCount: 0,
status: undefined,
setStatus: (status: string) =>
set(
produce((draftState) => {
draftState.importJobQueue.status = status;
}),
),
setJobCount: (jobType: string, count: Number) => {
switch (jobType) {
case "successful":
set(
produce((draftState) => {
draftState.importJobQueue.successfulJobCount = count;
}),
);
break;
case "failed":
set(
produce((draftState) => {
draftState.importJobQueue.failedJobCount = count;
}),
);
break;
}
},
mostRecentImport: null,
setMostRecentImport: (fileName: string) => {
set(
produce((state) => {
state.importJobQueue.mostRecentImport = fileName;
}),
);
},
},
})); }));
const { getState, setState } = useStore; const { getState, setState } = useStore;
const queryClient = new QueryClient();
/** Socket.IO initialization **/ // Method to init AirDC++ Socket with supplied settings
// 1. Fetch sessionId from localStorage const initializeAirDCPPSocket = async (configuration) => {
const sessionId = localStorage.getItem("sessionId"); console.log("[AirDCPP]: Initializing socket...");
// 2. socket.io instantiation
const socketIOInstance = io(SOCKET_BASE_URI, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
// 3. Set the instance in global state
setState({
socketIOInstance,
});
// Socket.io-based session restoration const initializedAirDCPPSocket = new AirDCPPSocket({
if (!isNil(sessionId)) { protocol: `${configuration.protocol}`,
// 1. Resume the session hostname: `${configuration.hostname}:${configuration.port}`,
socketIOInstance.emit( username: `${configuration.username}`,
"call", password: `${configuration.password}`,
"socket.resumeSession",
{
sessionId,
},
(data) => console.log(data),
);
} else {
// 1. Inititalize the session and persist the sessionId to localStorage
socketIOInstance.on("sessionInitialized", (sessionId) => {
localStorage.setItem("sessionId", sessionId);
}); });
}
// 2. If a job is in progress, restore the job counts and persist those to global state
socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
console.log("Active import in progress detected; restoring counts...");
const { completedJobCount, failedJobCount, queueStatus } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
failedJobCount,
status: queueStatus,
},
}));
});
// 1a. Act on each comic issue successfully imported/failed, as indicated // Set up connect and disconnect handlers
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events initializedAirDCPPSocket.onConnected = (sessionInfo) => {
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => { // update global state with socket connection status
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
* @param configuration - credentials, and hostname details to init AirDC++ connection
* @returns Initialized AirDC++ connection socket instance
*/
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
try {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
setState({ setState({
airDCPPSessionInformation, airDCPPSocketConnected: true,
}); });
};
initializedAirDCPPSocket.onDisconnected = async (reason, code, wasClean) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSocketConnectionInformation =
await initializedAirDCPPSocket.connect();
setState({
airDCPPSocketConnectionInformation,
});
// Set up event listeners // Set up event listeners
initializedAirDCPPSocket.addListener( initializedAirDCPPSocket.addListener(
`queue`, `queue`,
"queue_bundle_tick", "queue_bundle_tick",
async (downloadProgressData) => { async (downloadProgressData) => {
console.log(downloadProgressData); console.log(downloadProgressData);
setState({ },
airDCPPDownloadTick: downloadProgressData, );
}); initializedAirDCPPSocket.addListener(
}, "queue",
); "queue_bundle_added",
initializedAirDCPPSocket.addListener( async (data) => {
"queue", console.log("JEMEN:", data);
"queue_bundle_added", },
async (data) => { );
console.log("JEMEN:", data);
},
);
initializedAirDCPPSocket.addListener( initializedAirDCPPSocket.addListener(
`queue`, `queue`,
"queue_bundle_status", "queue_bundle_status",
async (bundleData) => { async (bundleData) => {
let count = 0; let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) { if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata // dispatch the action for raw import, with the metadata
if (count < 1) { if (count < 1) {
console.log(`[AirDCPP]: Download complete.`); console.log(`[AirDCPP]: Download complete.`);
count += 1; count += 1;
}
} }
}, }
); },
return initializedAirDCPPSocket; );
} catch (error) {
console.error(error);
}
}; };
// 1. get settings from mongo // 1. get settings from mongo
@@ -255,17 +93,14 @@ const { data } = await axios({
method: "GET", method: "GET",
}); });
const directConnectConfiguration = data?.directConnect?.client.host; const directConnectConfiguration = data?.directConnect.client.host;
// 2. If available, init AirDC++ Socket with those settings // 2. If available, init AirDC++ Socket with those settings
if (!isNil(directConnectConfiguration)) { if (!isEmpty(directConnectConfiguration)) {
const airDCPPSocketInstance = await initializeAirDCPPSocket( initializeAirDCPPSocket(directConnectConfiguration);
directConnectConfiguration,
);
setState({ setState({
airDCPPSocketInstance,
airDCPPClientConfiguration: directConnectConfiguration, airDCPPClientConfiguration: directConnectConfiguration,
}); });
} else {
console.log("problem");
} }
console.log("connected?", getState());

View File

View File

@@ -1,25 +0,0 @@
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()],
};

3532
yarn.lock

File diff suppressed because it is too large Load Diff