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
50 changed files with 12711 additions and 2444 deletions

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

@@ -25,7 +25,7 @@
"@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",
@@ -40,7 +40,6 @@
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"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",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
@@ -59,24 +58,23 @@
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"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-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", "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,15 +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.3.5",
"tui-jsdoc-template": "^1.2.2", "tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6" "typescript": "^5.1.6"
},
"resolutions": {
"styled-components": "^5"
} }
} }

View File

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

Binary file not shown.

Binary file not shown.

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 } from "@tanstack/react-query";
import axios from "axios";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
@@ -21,249 +27,107 @@ 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({});
// 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);
console.log("Asdas", airDCPPSearchInfo);
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`,
); );
},
if (!isNil(downloadResult)) { [dispatch, airDCPPConfiguration],
bundleDBImportResult = await axios({ );
method: "POST",
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
bundleId: downloadResult.bundle_info.id,
comicObjectId,
name,
size,
type,
},
});
// 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);
};
// download via AirDC++ // download via AirDC++
const downloadDCPPResult = useCallback( const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => { (searchInstanceId, resultId, name, size, type) => {
// dispatch( dispatch(
// downloadAirDCPPItem( downloadAirDCPPItem(
// searchInstanceId, searchInstanceId,
// resultId, resultId,
// props.comicObjectId, props.comicObjectId,
// name, name,
// size, size,
// type, type,
// airDCPPConfiguration.airDCPPState.socket, airDCPPConfiguration.airDCPPState.socket,
// { {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`, username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`, password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// }, },
// ), ),
// ); );
// this is to update the download count badge on the downloads tab // this is to update the download count badge on the downloads tab
// dispatch( dispatch(
// getBundlesForComic( getBundlesForComic(
// props.comicObjectId, props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket, airDCPPConfiguration.airDCPPState.socket,
// { {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`, username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`, password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// }, },
// ), ),
// ); );
}, },
[], [airDCPPConfiguration],
); );
return ( return (
<> <>
<div className="comic-detail columns"> <div className="comic-detail columns">
{!isEmpty(airDCPPSocketInstance) ? ( {!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
<Form <Form
onSubmit={getDCPPSearchResults} onSubmit={getDCPPSearchResults}
initialValues={{ initialValues={{
@@ -285,7 +149,9 @@ export const AcquisitionPanel = (
className="input main-search-bar is-medium" className="input main-search-bar is-medium"
placeholder="Type an issue/volume name" placeholder="Type an issue/volume name"
/> />
<span className="help is-clearfix is-light is-info"></span> <span className="help is-clearfix is-light is-info">
Use this to perform a manual search.
</span>
</div> </div>
); );
}} }}
@@ -295,9 +161,9 @@ export const AcquisitionPanel = (
<button <button
type="submit" type="submit"
className={ className={
airDCPPSearchStatus isAirDCPPSearchInProgress
? "button is-loading is-warning" ? "button is-loading is-warning"
: "button is-success is-light" : "button"
} }
> >
<span className="icon is-small"> <span className="icon is-small">
@@ -316,7 +182,7 @@ export const AcquisitionPanel = (
<article className="message is-info"> <article className="message is-info">
<div className="message-body is-size-6 is-family-secondary"> <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>
@@ -324,58 +190,48 @@ 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="columns"> <div className="card">
<div className="column is-one-quarter is-size-7"> <div className="card-content">
<div className="card"> <dl>
<div className="card-content"> <dt>
<dl> <div className="tags mb-1">
<dt> {airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
<div className="tags mb-1"> ({ value }) => (
{hubs.map((value, idx) => ( <span className="tag is-warning" key={value}>
<span className="tag is-warning" key={idx}> {value}
{value.identity.name}
</span> </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>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
</div>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<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>
</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">
@@ -403,7 +259,7 @@ export const AcquisitionPanel = (
<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>
@@ -443,25 +299,19 @@ export const AcquisitionPanel = (
</div> </div>
</td> </td>
<td> <td>
<button <a
className="button is-small is-light is-success"
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="icon"> <i className="fas fa-file-download"></i>
<i className="fas fa-file-download"></i> </a>
</span>
<span>Download </span>
</button>
</td> </td>
</tr> </tr>
); );
@@ -473,22 +323,10 @@ export const AcquisitionPanel = (
<div className="column is-three-fifths"> <div className="column is-three-fifths">
<article className="message is-info"> <article className="message is-info">
<div className="message-body is-size-6 is-family-secondary"> <div className="message-body is-size-6 is-family-secondary">
<p> Searching via <strong>AirDC++</strong> is still in{" "}
The default search term is an auto-detected title; you may <strong>alpha</strong>. Some searches may take arbitrarily long,
need to change it to get better matches if the auto-detected or may not work at all. Searches from <code>ADCS</code> hubs are
one doesn't work. more reliable than <code>NMDCS</code> ones.
</p>
</div>
</article>
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
<p className="content">
Searching via <strong>AirDC++</strong> is still in
<strong>alpha</strong>. Some searches may take arbitrarily
long, or may not work at all. Searches from <code>ADCS</code>
hubs are more reliable than <code>NMDCS</code> ones.
</p>
</div> </div>
</article> </article>
</div> </div>
@@ -498,4 +336,4 @@ export const AcquisitionPanel = (
); );
}; };
export default AcquisitionPanel; export default AcquisitionPanel;

View File

@@ -1,5 +1,6 @@
import { filter, isEmpty, isNil, isUndefined } from "lodash"; import { filter, isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useCallback } from "react"; import React, { ReactElement, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import Select, { components } from "react-select"; import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions"; import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser"; import { refineQuery } from "filename-parser";
@@ -7,6 +8,7 @@ import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => { export const Menu = (props): ReactElement => {
const { data } = props; const { data } = props;
const { setSlidingPanelContentId, setVisible } = props.handlers; const { setSlidingPanelContentId, setVisible } = props.handlers;
const dispatch = useDispatch();
const openDrawerWithCVMatches = useCallback(() => { const openDrawerWithCVMatches = useCallback(() => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery; let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
@@ -16,10 +18,10 @@ export const Menu = (props): ReactElement => {
} else if (!isEmpty(data.sourcedMetadata)) { } else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name); issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
} }
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery)); dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches"); setSlidingPanelContentId("CVMatches");
setVisible(true); setVisible(true);
}, [data]); }, [dispatch, data]);
const openEditMetadataPanel = useCallback(() => { const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata"); setSlidingPanelContentId("editComicBookMetadata");
@@ -69,29 +71,17 @@ export const Menu = (props): ReactElement => {
break; break;
} }
}; };
const customStyles = {
option: (base, { data, isDisabled, isFocused, isSelected }) => {
return {
...base,
backgroundColor: isFocused ? "gray" : "black",
};
},
control: (base) => ({
...base,
backgroundColor: "black",
border: "1px solid #CCC",
}),
};
return ( return (
<Select <Select
className="basic-single"
classNamePrefix="select"
components={{ Placeholder }} components={{ Placeholder }}
placeholder={ placeholder={
<span> <span>
<i className="fa-solid fa-list"></i> Actions <i className="fa-solid fa-list"></i> Actions
</span> </span>
} }
styles={customStyles}
name="actions" name="actions"
isSearchable={false} isSearchable={false}
options={filteredActionOptions} options={filteredActionOptions}

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";
@@ -46,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;
@@ -57,34 +55,34 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false); const [modalIsOpen, setIsOpen] = useState(false);
// const comicVineSearchResults = useSelector( const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults, (state: RootState) => state.comicInfo.searchResults,
// ); );
// const comicVineSearchQueryObject = useSelector( const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery, (state: RootState) => state.comicInfo.searchQuery,
// ); );
// const comicVineAPICallProgress = useSelector( const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress, (state: RootState) => state.comicInfo.inProgress,
// ); );
//
// const extractedComicBook = useSelector( const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading, (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,
// }, },
// }), }),
// ); );
}, []); }, []);
const afterOpenModal = useCallback((things) => { const afterOpenModal = useCallback((things) => {
@@ -102,7 +100,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
CVMatches: { CVMatches: {
content: (props) => ( content: (props) => (
<> <>
{/* <div className="card search-criteria-card"> <div className="card search-criteria-card">
<div className="card-content"> <div className="card-content">
<ComicVineSearchForm data={rawFileDetails} /> <ComicVineSearchForm data={rawFileDetails} />
</div> </div>
@@ -136,7 +134,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
/> />
</div> </div>
</div> </div>
)} */} )}
</> </>
), ),
}, },
@@ -195,9 +193,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
id: 3, id: 3,
icon: <i className="fa-regular fa-file-archive"></i>, icon: <i className="fa-regular fa-file-archive"></i>,
name: "Archive Operations", name: "Archive Operations",
content: <></>, content: <ArchiveOperations data={data.data} key={3} />,
/*
<ArchiveOperations data={data.data} key={3} /> */
shouldShow: areRawFileDetailsAvailable, shouldShow: areRawFileDetailsAvailable,
}, },
{ {
@@ -248,65 +244,71 @@ 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 }}
/> />
{/* raw file details */}
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && (
<div className="grid">
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
}}
/>
{/* <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>
)}
{/* action dropdown */} {/* action dropdown */}
{/* <div className="mt-4 is-size-7"> <div className="mt-4 is-size-7">
<Menu <Menu
data={data.data} data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }} handlers={{ setSlidingPanelContentId, setVisible }}
/> />
</div> */} </div>
</div>
{/* raw file details */}
<div className="column">
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails.cover) && (
<>
<RawFileDetails
data={{
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
}}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
<Modal
style={{ content: { marginTop: "2rem" } }}
isOpen={modalIsOpen}
onAfterOpen={afterOpenModal}
onRequestClose={closeModal}
contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}}
/>
)}
</Modal>
</>
)}
</div> </div>
</div> </div>
<TabControls {<TabControls filteredTabs={filteredTabs} />}
filteredTabs={filteredTabs}
acquisition={acquisition}
/>
<SlidingPane <SlidingPane
isOpen={visible} isOpen={visible}

View File

@@ -1,42 +1,22 @@
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react"; 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 { 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: [],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{
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,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;

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,10 +1,12 @@
import React, { useEffect, useContext, ReactElement } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions"; 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, isNil, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import dayjs from "dayjs"; import dayjs from "dayjs";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
interface IDownloadsPanelProps { interface IDownloadsPanelProps {
data: any; data: any;
@@ -14,33 +16,34 @@ interface IDownloadsPanelProps {
export const DownloadsPanel = ( export const DownloadsPanel = (
props: IDownloadsPanelProps, props: IDownloadsPanelProps,
): ReactElement | null => { ): ReactElement | null => {
// const bundles = useSelector((state: RootState) => { const bundles = useSelector((state: RootState) => {
// return state.airdcpp.bundles; return state.airdcpp.bundles;
// }); });
//
// // AirDCPP Socket initialization // AirDCPP Socket initialization
// const userSettings = useSelector((state: RootState) => state.settings.data); const userSettings = useSelector((state: RootState) => state.settings.data);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext); const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const { const {
airDCPPState: { socket, settings }, airDCPPState: { socket, settings },
} = airDCPPConfiguration; } = 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++
useEffect(() => { useEffect(() => {
try { try {
if (!isEmpty(userSettings)) { if (!isEmpty(userSettings)) {
// dispatch( dispatch(
// getBundlesForComic(props.comicObjectId, socket, { getBundlesForComic(props.comicObjectId, socket, {
// username: `${settings.directConnect.client.host.username}`, username: `${settings.directConnect.client.host.username}`,
// password: `${settings.directConnect.client.host.password}`, password: `${settings.directConnect.client.host.password}`,
// }), }),
// ); );
} }
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
} }
}, [airDCPPConfiguration]); }, [dispatch, airDCPPConfiguration]);
const Bundles = (props) => { const Bundles = (props) => {
return !isEmpty(props.data) ? ( return !isEmpty(props.data) ? (

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,7 +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 AsyncSelectPaginateAdapter = ({ input, ...rest }) => { const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return ( return (
<AsyncSelectPaginate <AsyncSelectPaginate
@@ -27,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 (
<> <>

View File

@@ -1,4 +1,5 @@
import React, { useCallback } 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 { applyComicVineMatch } from "../../actions/comicinfo.actions";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
@@ -14,11 +15,12 @@ const handleBrokenImage = (e) => {
}; };
export const MatchResult = (props: MatchResultProps) => { export const MatchResult = (props: MatchResultProps) => {
const dispatch = useDispatch();
const applyCVMatch = useCallback( const applyCVMatch = useCallback(
// (match, comicObjectId) => { (match, comicObjectId) => {
// dispatch(applyComicVineMatch(match, comicObjectId)); dispatch(applyComicVineMatch(match, comicObjectId));
// }, },
[], [dispatch],
); );
return ( return (
<> <>

View File

@@ -2,137 +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;
const PaperClipIcon = () => <></>;
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"> <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"> <dd>
Raw File Details <div className="field is-grouped mt-2">
</dt> <div className="control">
<dd className="mt-1 text-sm text-gray-900"> <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">
Inferred Issue Metadata
</dt>
<dd className="mt-1 text-sm text-gray-900">
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">MIMEType</dt>
<dd className="mt-1 text-sm text-gray-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="pr-1 pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.mimeType}
</span>
</span>
</dd>
<dd className="text-sm text-gray-900"></dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">File Size</dt>
<dd className="mt-1 text-sm text-gray-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"> </dl>
Import Details </div>
</dt>
<dd className="mt-1 text-sm text-gray-900"> <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">Attachments</dt> </dl>
<dd className="mt-1 text-sm text-gray-900">
<ul
role="list"
className="divide-y divide-gray-200 rounded-md border border-gray-200"
>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
<PaperClipIcon
className="h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
<span className="ml-2 w-0 flex-1 truncate">
resume_back_end_developer.pdf
</span>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => {}}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
</ul>
</dd>
</div>
</dl>
</div>
</div> </div>
</> </>
); );
@@ -161,7 +102,5 @@ RawFileDetails.propTypes = {
subtitle: PropTypes.string, subtitle: PropTypes.string,
}), }),
}), }),
created_at: PropTypes.string,
updated_at: PropTypes.string,
}), }),
}; };

View File

@@ -1,47 +1,49 @@
import React, { ReactElement, useEffect, useState } from "react"; import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash"; import { isEmpty, isNil } from "lodash";
import { useSelector } from "react-redux";
export const TabControls = (props): ReactElement => { export const TabControls = (props): ReactElement => {
// const comicBookDetailData = useSelector( const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail, (state: RootState) => state.comicInfo.comicBookDetail,
// ); );
const { filteredTabs, acquisition } = props; const { filteredTabs } = props;
const [active, setActive] = useState(filteredTabs[0].id); const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => { useEffect(() => {
setActive(filteredTabs[0].id); setActive(filteredTabs[0].id);
}, [acquisition]); }, [comicBookDetailData]);
return ( return (
<> <>
<div className="hidden sm:block mt-7"> <div className="tabs">
<div className="border-b border-gray-200"> <ul>
<nav className="-mb-px 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 border-b-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" onClick={() => setActive(id)}
aria-current="page" >
onClick={() => setActive(id)} {/* Downloads tab and count badge */}
> <a>
{/* Downloads tab and count badge */} {id === 6 &&
{/* <a> !isNil(comicBookDetailData.acquisition.directconnect) ? (
{id === 6 && !isNil(acquisition.directconnect) ? (
<span className="download-icon-labels"> <span className="download-icon-labels">
<i className="fa-solid fa-download"></i> <i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light"> <span className="tag downloads-count is-info is-light">
{acquisition.directconnect.downloads.length} {
comicBookDetailData.acquisition.directconnect.downloads
.length
}
</span> </span>
</span> </span>
) : ( ) : (
<span className="icon is-small">{icon}</span> <span className="icon is-small">{icon}</span>
)} )}
{name} {name}
</a> */}
{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,4 +1,5 @@
import React, { ReactElement, useCallback, 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 Sticky from "react-stickynode";
@@ -9,27 +10,28 @@ import { Canvas } from "../../shared/Canvas";
export const ArchiveOperations = (props): ReactElement => { export const ArchiveOperations = (props): ReactElement => {
const { data } = props; const { data } = props;
// const isComicBookExtractionInProgress = useSelector( const isComicBookExtractionInProgress = useSelector(
// (state: RootState) => state.fileOps.comicBookExtractionInProgress, (state: RootState) => state.fileOps.comicBookExtractionInProgress,
// ); );
// const extractedComicBookArchive = useSelector( const extractedComicBookArchive = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.analysis, (state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
// ); );
//
// const imageAnalysisResult = useSelector((state: RootState) => {
// return state.fileOps.imageAnalysisResults;
// });
const imageAnalysisResult = useSelector((state: RootState) => {
return state.fileOps.imageAnalysisResults;
});
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => { const unpackComicArchive = useCallback(() => {
// dispatch( dispatch(
// extractComicArchive(data.rawFileDetails.filePath, { extractComicArchive(data.rawFileDetails.filePath, {
// type: "full", type: "full",
// purpose: "analysis", purpose: "analysis",
// imageResizeOptions: { imageResizeOptions: {
// baseWidth: 275, baseWidth: 275,
// }, },
// }), }),
// ); );
}, []); }, []);
// sliding panel config // sliding panel config
@@ -62,7 +64,7 @@ export const ArchiveOperations = (props): ReactElement => {
// sliding panel handlers // sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => { const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis"); setSlidingPanelContentId("imageAnalysis");
// dispatch(analyzeImage(imageFilePath)); dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath); setCurrentImage(imageFilePath);
setVisible(true); setVisible(true);
}, []); }, []);
@@ -124,4 +126,4 @@ export const ArchiveOperations = (props): ReactElement => {
); );
}; };
export default ArchiveOperations; export default ArchiveOperations;

View File

@@ -11,40 +11,10 @@ import {
getComicBooks, getComicBooks,
} from "../../actions/fileops.actions"; } from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions"; import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil } from "lodash";
import Header from "../shared/Header"; import Header from "../shared/Header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Card } from "../shared/Carda";
import {
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../constants/endpoints";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
export const Dashboard = (): ReactElement => { export const Dashboard = (): ReactElement => {
const { data: recentComics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
},
}),
queryKey: ["recentComics"],
});
console.log("hari om", recentComics);
// useEffect(() => { // useEffect(() => {
// dispatch(fetchVolumeGroups()); // dispatch(fetchVolumeGroups());
// dispatch( // dispatch(
@@ -86,102 +56,9 @@ export const Dashboard = (): ReactElement => {
// (state: RootState) => state.comicInfo.libraryStatistics, // (state: RootState) => state.comicInfo.libraryStatistics,
// ); // );
return ( return (
<div className="container mx-auto max-w-full"> <div className="container">
<section> <section className="section">
<h1>Dashboard</h1> <h1 className="title">Dashboard</h1>
<div className="grid grid-cols-5 gap-6">
{recentComics?.data.docs.map(
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(
name,
{
comicvine,
comicInfo,
locg,
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
return (
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dt className="sr-only">Address</dt>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline]"></i>
</span>
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
</dd>
</div>
<div className="flex flex-row items-center gap-1 my-2">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300"></i>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
);
},
)}
</div>
</section> </section>
</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 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,13 +1,12 @@
import React, { useMemo, ReactElement, useState } 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 { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { useQuery, keepPreviousData } 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.
@@ -17,36 +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 searchError = useSelector((state: RootState) => state.fileOps.librarySearchError);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 15,
from: 0,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
// Method to fetch paginated issues // programatically navigate to comic detail
const fetchIssues = async (searchQuery, offset, type) => {
let pagination = {
size: 15,
from: offset,
};
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
searchQuery,
pagination,
type,
},
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset],
queryFn: () => fetchIssues({}, offset, "all"),
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}`);
@@ -54,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 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(
() => [ () => [
{ {
@@ -108,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>
),
}, },
], ],
}, },
@@ -119,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-xs w-max ml-3 my-3 text-slate-600"> !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.downloads.length}
</span>
</span>
</div>
),
}, },
], ],
}, },
@@ -169,11 +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(
setOffset(pageSize * pageIndex + 1); searchIssue(
} {
}; query: {},
},
{
pagination: {
size: pageSize,
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
@@ -182,44 +181,46 @@ 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;
} }
setOffset(from); dispatch(
}; searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
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 className="library"> <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,
@@ -229,36 +230,28 @@ export const Library = (): ReactElement => {
</div> </div>
</div> </div>
) : ( ) : (
<> <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>
)} )}
</section> </div>
</div> </section>
); );
}; };

View File

@@ -2,65 +2,62 @@ 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"; import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (): ReactElement => { export const SearchBar = (): ReactElement => {
const dispatch = useDispatch();
const handleSubmit = useCallback((e) => { const handleSubmit = useCallback((e) => {
// dispatch( dispatch(
// searchIssue( searchIssue(
// { {
// query: { query: {
// volumeName: e.search, volumeName: e.search,
// }, },
// }, },
// { {
// pagination: { pagination: {
// size: 25, size: 25,
// from: 0, from: 0,
// }, },
// type: "volumeName", type: "volumeName",
// trigger: "libraryPage", trigger: "libraryPage",
// }, },
// ), ),
// ); );
}, []); }, []);
return ( return (
<Form <div className="box">
onSubmit={handleSubmit} <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,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,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,8 +13,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "GET", method: "GET",
}), }),
}); });
console.log(data); 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({ const { data: connectionDetails } = useQuery({
queryKey: [], queryKey: [],
@@ -37,6 +35,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
}), }),
enabled: !!connectionDetails, 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) =>
@@ -45,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)
@@ -67,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

@@ -14,11 +14,7 @@ export const Settings = (props: ISettingsProps): ReactElement => {
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",
@@ -42,53 +38,28 @@ export const Settings = (props: ISettingsProps): ReactElement => {
}, },
{ {
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">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Settings
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
<div className="flex flex-cols max-w-screen-xl mx-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => { {map(settingsObject, (settingObject, idx) => {
return ( return (
<div <div key={idx}>
className="w-64 py-2 text-slate-700 dark:text-slate-400" <p className="menu-label">{settingObject.category}</p>
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */} {/* First level children */}
{!isUndefined(settingObject.children) ? ( {!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}> <ul className="menu-list" key={settingObject.id}>
{map(settingObject.children, (item, idx) => { {map(settingObject.children, (item, idx) => {
return ( return (
<li key={idx} className="mb-2"> <li key={idx}>
<a <a
className={ className={
item.id.toString() === active item.id.toString() === active ? "is-active" : ""
? "is-active flex items-center"
: "flex items-center"
} }
onClick={() => setActive(item.id.toString())} onClick={() => setActive(item.id.toString())}
> >
@@ -96,14 +67,14 @@ export const Settings = (props: ISettingsProps): ReactElement => {
</a> </a>
{/* Second level children */} {/* Second level children */}
{!isUndefined(item.children) ? ( {!isUndefined(item.children) ? (
<ul className="pl-4 mt-2"> <ul>
{map(item.children, (item, idx) => ( {map(item.children, (item, idx) => (
<li key={item.id} className="mb-2"> <li key={item.id}>
<a <a
className={ className={
item.id.toString() === active item.id.toString() === active
? "is-active flex items-center" ? "is-active"
: "flex items-center" : ""
} }
onClick={() => onClick={() =>
setActive(item.id.toString()) setActive(item.id.toString())
@@ -124,18 +95,18 @@ export const Settings = (props: ISettingsProps): ReactElement => {
); );
})} })}
</aside> </aside>
</div>
{/* content for settings */} {/* content for settings */}
<div className="max-w-screen-xl"> <div className="section column is-half mt-6">
<div className="content"> <div className="content">
{map(settingsContent, ({ id, content }) => {map(settingsContent, ({ id, content }) =>
active === id ? content : null, 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,4 +1,5 @@
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";
@@ -7,23 +8,24 @@ import { convert } from "html-to-text";
import { isUndefined } from "lodash"; import { isUndefined } from "lodash";
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 dispatch = useDispatch();
useEffect(() => { useEffect(() => {
// dispatch( dispatch(
// searchIssue( searchIssue(
// { {
// query: {}, query: {},
// }, },
// { {
// pagination: { pagination: {
// size: 25, size: 25,
// from: 0, from: 0,
// }, },
// type: "volumes", type: "volumes",
// trigger: "volumesPage", trigger: "volumesPage",
// }, },
// ), ),
// ); );
}, []); }, []);
const columnData = useMemo( const columnData = useMemo(
() => [ () => [

View File

@@ -1,4 +1,5 @@
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 { 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";
@@ -6,25 +7,26 @@ import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";
export const WantedComics = (props): ReactElement => { export const WantedComics = (props): ReactElement => {
// const wantedComics = useSelector( const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics, (state: RootState) => state.fileOps.wantedComics,
// ); );
const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
// dispatch( dispatch(
// searchIssue( searchIssue(
// { {
// query: {}, query: {},
// }, },
// { {
// pagination: { pagination: {
// size: 25, size: 25,
// from: 0, from: 0,
// }, },
// type: "wanted", type: "wanted",
// trigger: "wantedComicsPage" trigger: "wantedComicsPage"
// }, },
// ), ),
// ); );
}, []); }, []);
const columnData = [ const columnData = [
@@ -35,7 +37,7 @@ 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) => <MetadataPanel data={value.getValue()} />, cell: (value) => <MetadataPanel data={value.getValue()} />,
}, },
], ],
@@ -47,10 +49,8 @@ export const WantedComics = (props): ReactElement => {
header: "Files", header: "Files",
accessorKey: "acquisition", accessorKey: "acquisition",
align: "right", align: "right",
cell: (props) => { cell: props => {
const { const { directconnect: { downloads } } = props.getValue();
directconnect: { downloads },
} = props.getValue();
return ( return (
<div <div
style={{ style={{
@@ -60,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>
); );
@@ -70,17 +72,11 @@ export const WantedComics = (props): ReactElement => {
header: "Download Details", header: "Download Details",
id: "downloadDetails", id: "downloadDetails",
accessorKey: "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",
@@ -96,7 +92,7 @@ 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(
@@ -116,6 +112,7 @@ export const WantedComics = (props): ReactElement => {
); );
}, []); }, []);
/** /**
* Pagination control that fetches the previous x (pageSize) items * Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index * based on the y (pageIndex) offset from the Elasticsearch index
@@ -141,7 +138,7 @@ export const WantedComics = (props): ReactElement => {
from, from,
}, },
type: "wanted", type: "wanted",
trigger: "wantedComicsPage", trigger: "wantedComicsPage"
}, },
), ),
); );
@@ -164,7 +161,7 @@ export const WantedComics = (props): ReactElement => {
nextPage: nextPage, nextPage: nextPage,
previousPage: previousPage, previousPage: previousPage,
}} }}
// rowClickHandler={navigateToComicDetail} // rowClickHandler={navigateToComicDetail}
/> />
{/* pagination controls */} {/* pagination controls */}
</div> </div>

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,85 +80,6 @@ const renderCard = (props: ICardProps): ReactElement => {
</div> </div>
</div> </div>
); );
case "vertical-2":
return (
<div className="block rounded-md w-fit 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"
/>
<div className="mt-2 px-2">
<dl>
<div>
<dd className="text-md text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
</dl>
{props.hasDetails && <>{props.children}</>}
</div>
</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,5 +0,0 @@
import React from "react";
export const ErrorPage = () => {
return <>Error has been encountered.</>;
};

View File

@@ -31,53 +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>
</dd> </dd>
</dl> </dl>
), ),
@@ -200,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,121 +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>
<a
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Volumes
</a>
</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>
</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

@@ -12,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({
@@ -43,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 = () => {
@@ -55,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,
@@ -70,52 +66,62 @@ export const T2Table = (tableOptions): ReactElement => {
}); });
return ( return (
<div className="container max-w-fit mx-14"> <>
<div> <div className="columns table-controls">
{/* Search bar */} {/* Search bar */}
<div className="flex flex-row gap-2 justify-between mt-5"> <div className="column is-half">
<SearchBar /> <SearchBar />
</div>
{/* pagination controls */}
<nav className="pagination columns">
<div className="mr-4 has-text-weight-semibold has-text-left">
<p className="is-size-5">Page {pageIndex} of {Math.ceil(totalPages / pageSize)}</p>
{/* <p>{totalPages} comics in all</p> */}
</div>
<div className="field has-addons">
<div className="control">
<div className="button" onClick={() => goToPreviousPage()}> <i className="fas fa-chevron-left"></i></div>
</div>
<div className="control">
<div className="button" onClick={() => goToNextPage()}> <i className="fas fa-chevron-right"></i> </div>
</div>
{/* pagination controls */} <div className="field has-addons ml-5">
<div> <p className="control">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} <button className="button">
<p>{totalPages} comics in all</p> <span className="icon is-small">
{/* Prev/Next buttons */} <i className="fa-solid fa-list"></i>
<div className="inline-flex flex-row mt-4 mb-4"> </span>
<button </button>
onClick={() => goToPreviousPage()} </p>
disabled={pageIndex === 1} <p className="control">
className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2" <button className="button">
> <Link to="/library-grid">
<i className="icon-[solar--arrow-left-linear] h-6 w-6"></i> <span className="icon is-small">
</button> <i className="fa-solid fa-image"></i>
<button </span>
className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2" </Link>
onClick={() => goToNextPage()} </button>
disabled={pageIndex > Math.floor(totalPages / pageSize)} </p>
>
<i className="icon-[solar--arrow-right-linear] h-6 w-6"></i>
</button>
</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>
@@ -125,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> </>
); );
}; };

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,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,15 +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 { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -20,20 +15,10 @@ const router = createBrowserRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <ErrorPage />, },
children: [ {
{ path: "dashboard", element: <Dashboard /> }, path: "/settings",
{ path: "settings", element: <Settings /> }, element: <Settings />,
{
path: "library",
element: <TabulatedContentContainer category="library" />,
},
{
path: "comic/details/:comicObjectId",
element: <ComicDetailContainer />,
},
{ path: "import", element: <Import path={"./comics"} /> },
],
}, },
]); ]);

View File

@@ -1,237 +1,90 @@
import { create } from "zustand"; import { create } from "zustand";
import { isEmpty, isNil, isUndefined } 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
socketIOInstance: {},
// 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;
}),
);
},
}, },
// Socket.io state
})); }));
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"] });
});
/**
* 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
@@ -240,19 +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()); console.log("connected?", getState());

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()],
};

10556
yarn.lock Normal file

File diff suppressed because it is too large Load Diff