Compare commits

..

1 Commits

Author SHA1 Message Date
d72f666a51 🔧 Refactor for axios upgrade 2023-03-25 16:31:24 -04:00
116 changed files with 12752 additions and 10477 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Publish to Registry - name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@v5 uses: elgohr/Publish-Docker-Github-Action@master
with: with:
name: frishi/threetwo name: frishi/threetwo
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}

1
.gitignore vendored
View File

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

View File

@@ -1,11 +1,9 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [ addons: [
"@storybook/addon-links", "@storybook/addon-links",
"@storybook/addon-essentials", "@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions", "@storybook/addon-interactions",
], ],
framework: { framework: {

View File

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

View File

@@ -40,8 +40,9 @@ For debugging and troubleshooting, you can run this app locally using these step
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git` 1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
2. `yarn run dev` (you can ignore the warnings) 2. `yarn run dev` (you can ignore the warnings)
3. This will open `http://localhost:5173` in your default browser 3. This will open `http://localhost:3050` in your default browser
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work. 4. For testing `OPDS` functionality, create a folder called `comics` under `/src/server` and put some comics in there. The `OPDS` feed is accessed to `http://localhost:8050/api/opds`
5. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## Troubleshooting ## Troubleshooting

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
{ {
"name": "threetwo", "name": "threetwo",
"version": "0.1.0", "version": "0.0.2",
"description": "ThreeTwo! A good comic book curator.", "description": "ThreeTwo! A comic book curator.",
"main": "server/index.js", "main": "server/index.js",
"typings": "server/index.js", "typings": "server/index.js",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "rimraf dist && npm run build && vite", "dev": "rimraf dist && npm run build && vite",
"start": "npm run build && vite", "prod": "npm run build && vite",
"docs": "jsdoc -c jsdoc.json", "docs": "jsdoc -c jsdoc.json",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build"
@@ -15,22 +15,28 @@
"author": "Rishi Ghan", "author": "Rishi Ghan",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.0.8", "@babel/runtime": "^7.13.17",
"@dnd-kit/sortable": "^7.0.2", "@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@dnd-kit/utilities": "^3.2.1", "@dnd-kit/core": "^4.0.0",
"@dnd-kit/sortable": "^5.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.3.0",
"@popperjs/core": "^2.11.8", "@redux-devtools/extension": "^3.2.2",
"@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5", "@tanstack/react-table": "^8.8.0",
"@tanstack/react-table": "^8.9.3", "@types/axios": "^0.14.0",
"@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", "@types/socket.io": "^3.0.2",
"airdcpp-apisocket": "^2.5.0-beta.2", "@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"airdcpp-apisocket": "2.4.5-beta.1",
"axios": "^1.3.4", "axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1", "axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0", "axios-rate-limit": "^1.3.0",
"babel-plugin-styled-components": "^2.1.4", "babel-polyfill": "^6.26.0",
"babel-preset-minify": "^0.5.2",
"better-docs": "^2.7.2",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"dayjs": "^1.10.6", "dayjs": "^1.10.6",
"ellipsize": "^0.5.1", "ellipsize": "^0.5.1",
@@ -38,55 +44,61 @@
"filename-parser": "^1.0.2", "filename-parser": "^1.0.2",
"final-form": "^4.20.2", "final-form": "^4.20.2",
"final-form-arrays": "^3.0.2", "final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3",
"history": "^5.3.0", "history": "^5.3.0",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"immer": "^10.0.3",
"jsdoc": "^3.6.10", "jsdoc": "^3.6.10",
"keen-slider": "^6.8.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "npm:sass",
"pretty-bytes": "^5.6.0", "pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qs": "^6.10.5", "qs": "^6.10.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-collapsible": "^2.9.0", "react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0", "react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.6.0",
"react-dom": "^18.2.0", "react-dom": "^18.1.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4", "react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0", "react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1", "react-modal": "^3.15.1",
"react-popper": "^2.3.0", "react-redux": "^8.0.5",
"react-router": "^6.9.0", "react-router": "^6.9.0",
"react-router-dom": "^6.9.0", "react-router-dom": "^6.9.0",
"react-select": "^5.8.0", "react-select": "^5.3.2",
"react-select-async-paginate": "^0.7.2", "react-select-async-paginate": "^0.7.2",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0", "react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1", "reapop": "^4.2.1",
"redux-first-history": "^5.1.1",
"redux-socket.io-middleware": "^1.0.4",
"redux-thunk": "^2.4.2",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2", "socket.io-client": "^4.3.2",
"styled-components": "^6.1.0", "styled-components": "^5.3.9",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.5",
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34", "websocket": "^1.0.34",
"zustand": "^4.4.6" "xregexp": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/solar": "^1.1.8", "@babel/cli": "^7.13.10",
"@iconify/tailwind": "^0.1.4", "@babel/core": "^7.13.10",
"@storybook/addon-essentials": "^7.4.1", "@babel/plugin-syntax-top-level-await": "^7.14.5",
"@storybook/addon-interactions": "^7.4.1", "@babel/plugin-transform-runtime": "^7.13.15",
"@storybook/addon-links": "^7.4.1", "@babel/preset-env": "^7.20.2",
"@storybook/addon-onboarding": "^1.0.8", "@babel/preset-react": "^7.18.6",
"@storybook/blocks": "^7.4.1", "@babel/preset-typescript": "^7.13.0",
"@storybook/react": "^7.4.1", "@storybook/addon-essentials": "^7.0.0-rc.3",
"@storybook/react-vite": "^7.4.1", "@storybook/addon-interactions": "^7.0.0-rc.3",
"@storybook/testing-library": "^0.2.0", "@storybook/addon-links": "^7.0.0-rc.3",
"@tanstack/eslint-plugin-query": "^5.0.5", "@storybook/blocks": "^7.0.0-rc.3",
"@tanstack/react-query-devtools": "^5.1.0", "@storybook/react": "^7.0.0-rc.3",
"@storybook/react-vite": "^7.0.0-rc.3",
"@storybook/testing-library": "^0.0.14-next.1",
"@tsconfig/node14": "^1.0.0", "@tsconfig/node14": "^1.0.0",
"@types/ellipsize": "^0.1.1", "@types/ellipsize": "^0.1.1",
"@types/express": "^4.17.8", "@types/express": "^4.17.8",
@@ -96,34 +108,33 @@
"@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", "@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"babel-eslint": "^10.0.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"docdash": "^2.0.2", "bulma": "^0.9.4",
"eslint": "^8.49.0", "eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0", "eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsdoc": "^46.6.0",
"eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0", "eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.13", "eslint-plugin-storybook": "^0.6.11",
"express": "^4.17.1", "express": "^4.17.1",
"install": "^0.13.0", "install": "^0.13.0",
"jest": "^29.6.3", "jest": "^26.6.3",
"nodemon": "^3.0.1", "nodemon": "^1.17.3",
"postcss": "^8.4.32", "npm": "^8.11.0",
"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.58.1",
"storybook": "^7.3.2", "storybook": "^7.0.0-rc.3",
"tailwindcss": "^3.4.1", "tslint": "^6.1.3",
"tui-jsdoc-template": "^1.2.2", "typescript": "^5.0.2",
"typescript": "^5.1.6" "vite": "^4.2.0"
},
"resolutions": {
"jackspeak": "2.1.1"
} }
} }

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

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,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import rateLimiter from "axios-rate-limit"; import rateLimiter from "axios-rate-limit";
import { setupCache } from "axios-cache-interceptor"; import { setupCache } from "axios-cache-interceptor";
import qs from "qs";
import { import {
CV_SEARCH_SUCCESS, CV_SEARCH_SUCCESS,
CV_API_CALL_IN_PROGRESS, CV_API_CALL_IN_PROGRESS,
@@ -33,6 +34,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
dispatch({ dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS, type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
}); });
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, { await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get", method: "get",
params: options, params: options,
@@ -53,6 +55,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
type: CV_API_CALL_IN_PROGRESS, type: CV_API_CALL_IN_PROGRESS,
inProgress: true, inProgress: true,
}); });
console.log(options);
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`; const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
const response = await http(serviceURI, { const response = await http(serviceURI, {
method: options.callMethod, method: options.callMethod,
@@ -62,6 +65,11 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
"Content-Type": "application/json", "Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
paramsSerializer: {
indexes: null,
},
// (params) =>
// qs.stringify(params, { arrayFormat: "repeat" }),
}); });
switch (options.callURIAction) { switch (options.callURIAction) {
@@ -76,7 +84,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
console.log("Could not complete request."); console.log("Could not complete request.");
} }
} catch (error) { } catch (error) {
console.log(error); console.error(error);
dispatch({ dispatch({
type: CV_API_GENERIC_FAILURE, type: CV_API_GENERIC_FAILURE,
error, error,
@@ -126,7 +134,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
queryObjects, queryObjects,
}, },
}); });
dispatch({ dispatch({
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED, type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
matches: foo.data, matches: foo.data,

View File

@@ -4,12 +4,13 @@ import {
COMICVINE_SERVICE_URI, COMICVINE_SERVICE_URI,
IMAGETRANSFORMATION_SERVICE_BASE_URI, IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI, LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
SEARCH_SERVICE_BASE_URI, SEARCH_SERVICE_BASE_URI,
JOB_QUEUE_SERVICE_BASE_URI,
} from "../constants/endpoints"; } from "../constants/endpoints";
import { import {
IMS_COMIC_BOOK_GROUPS_FETCHED, IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS, IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_RECENT_COMICS_FETCHED, IMS_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED, IMS_WANTED_COMICS_FETCHED,
CV_API_CALL_IN_PROGRESS, CV_API_CALL_IN_PROGRESS,
@@ -21,38 +22,23 @@ import {
LS_IMPORT, LS_IMPORT,
IMG_ANALYSIS_CALL_IN_PROGRESS, IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS, IMG_ANALYSIS_DATA_FETCH_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS, IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
SS_SEARCH_RESULTS_FETCHED, SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS, SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET, FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS, LS_IMPORT_CALL_IN_PROGRESS,
LS_TOGGLE_IMPORT_QUEUE,
SS_SEARCH_FAILED, SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL, SS_SEARCH_RESULTS_FETCHED_SPECIAL,
WANTED_COMICS_FETCHED, WANTED_COMICS_FETCHED,
VOLUMES_FETCHED, VOLUMES_FETCHED,
LIBRARY_SERVICE_HEALTH, CV_WEEKLY_PULLLIST_FETCHED,
LS_SET_QUEUE_STATUS,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types"; } from "../constants/action-types";
import { success } from "react-notification-system-redux"; import { success } from "react-notification-system-redux";
import { isNil } from "lodash"; import { isNil, map } from "lodash";
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
axios
.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
method: "GET",
transformResponse: (r: string) => JSON.parse(r),
})
.then((response) => {
const { data } = response;
dispatch({
type: LIBRARY_SERVICE_HEALTH,
status: data,
});
});
};
export async function walkFolder(path: string): Promise<Array<IFolderData>> { export async function walkFolder(path: string): Promise<Array<IFolderData>> {
return axios return axios
.request<Array<IFolderData>>({ .request<Array<IFolderData>>({
@@ -88,38 +74,19 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
// autoDismiss: 0, // autoDismiss: 0,
// }), // }),
// ); // );
const sessionId = localStorage.getItem("sessionId");
dispatch({ dispatch({
type: LS_IMPORT, type: LS_IMPORT,
}); meta: { remote: true },
data: {},
await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
method: "POST",
data: { sessionId },
}); });
}; };
export const toggleImportQueueStatus = (options) => async (dispatch) => {
export const getImportJobResultStatistics = () => async (dispatch) => {
const result = await axios.request({
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
method: "GET",
});
dispatch({ dispatch({
type: LS_IMPORT_JOB_STATISTICS_FETCHED, type: LS_TOGGLE_IMPORT_QUEUE,
data: result.data, meta: { remote: true },
data: { manjhul: "jigyadam", action: options.action },
}); });
}; };
export const setQueueControl =
(queueAction: string, queueStatus: string) => async (dispatch) => {
dispatch({
type: LS_SET_QUEUE_STATUS,
meta: { remote: true },
data: { queueAction, queueStatus },
});
};
/** /**
* Fetches comic book metadata for various types * Fetches comic book metadata for various types
* @return metadata for the comic book object categories * @return metadata for the comic book object categories
@@ -160,50 +127,49 @@ export const getComicBooks = (options) => async (dispatch) => {
* @returns Nothing. * @returns Nothing.
* @param payload * @param payload
*/ */
export const importToDB = export const importToDB = (sourceName: string, metadata?: any) => (dispatch) => {
(sourceName: string, metadata?: any) => (dispatch) => { try {
try { const comicBookMetadata = {
const comicBookMetadata = { importType: "new",
importType: "new", payload: {
payload: { rawFileDetails: {
rawFileDetails: { name: "",
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
}, },
}; importStatus: {
dispatch({ isImported: true,
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS, tagged: false,
}); matchedResult: {
return axios score: "0",
.request({ },
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`, },
method: "POST", sourcedMetadata: metadata || null,
data: comicBookMetadata, acquisition: { source: { wanted: true, name: sourceName } },
// transformResponse: (r: string) => JSON.parse(r), }
}) };
.then((response) => { dispatch({
const { data } = response; type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
dispatch({ });
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL, return axios
importResult: data, .request({
}); url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: comicBookMetadata,
// transformResponse: (r: string) => JSON.parse(r),
})
.then((response) => {
const { data } = response;
dispatch({
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
importResult: data,
}); });
} catch (error) {
dispatch({
type: IMS_CV_METADATA_IMPORT_FAILED,
importError: error,
}); });
} } catch (error) {
}; dispatch({
type: IMS_CV_METADATA_IMPORT_FAILED,
importError: error,
});
}
};
export const fetchVolumeGroups = () => async (dispatch) => { export const fetchVolumeGroups = () => async (dispatch) => {
try { try {
@@ -288,23 +254,24 @@ export const fetchComicVineMatches =
* @returns {any} * @returns {any}
*/ */
export const extractComicArchive = export const extractComicArchive =
(path: string, options: any): any => (path: string, options: any): any =>
async (dispatch) => { async (dispatch) => {
dispatch({ dispatch({
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS, type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
}); });
await axios({ await axios({
method: "POST", method: "POST",
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`, url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
}, },
data: { data: {
filePath: path, filePath: path,
options, options,
}, },
}); });
}; };
/** /**
* Description * Description
@@ -382,4 +349,4 @@ export const analyzeImage =
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS, type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
result: foo.data, result: foo.data,
}); });
}; };

View File

@@ -3,14 +3,25 @@ import {
SETTINGS_OBJECT_FETCHED, SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS, SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS, SETTINGS_DB_FLUSH_SUCCESS,
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED, } from "../constants/action-types";
} from "../reducers/settings.reducer";
import { import {
LIBRARY_SERVICE_BASE_URI, LIBRARY_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} from "../constants/endpoints"; } from "../constants/endpoints";
export const saveSettings =
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
method: "POST",
data: { settingsPayload, settingsObjectId },
});
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: result.data,
});
};
export const getSettings = (settingsKey?) => async (dispatch) => { export const getSettings = (settingsKey?) => async (dispatch) => {
const result = await axios({ const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`, url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
@@ -56,22 +67,3 @@ export const flushDb = () => async (dispatch) => {
}); });
} }
}; };
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
method: "POST",
data: hostInfo,
});
const qBittorrentClientInfo = await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
method: "GET",
});
dispatch({
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
data: qBittorrentClientInfo.data,
});
};
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};

View File

@@ -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

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

View File

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

View File

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

View File

@@ -1,14 +1,139 @@
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 "../assets/scss/App.scss";
import Import from "./Import";
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
import LibraryGrid from "./Library/LibraryGrid";
import Search from "./Search";
import Settings from "./Settings";
import VolumeDetail from "./VolumeDetail/VolumeDetail";
import Downloads from "./Downloads/Downloads";
import { Routes, Route } from "react-router-dom";
import Navbar from "./Navbar";
import "../assets/scss/App.scss";
import {
AirDCPPSocketContextProvider,
AirDCPPSocketContext,
} from "../context/AirDCPPSocket";
import { isEmpty, isUndefined } from "lodash";
import {
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
LS_SINGLE_IMPORT,
} from "../constants/action-types";
import { useDispatch, useSelector } from "react-redux";
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
const AirDCPPSocketComponent = (): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
useEffect(() => {
const initializeAirDCPPEventListeners = async () => {
if (
!isUndefined(airDCPPConfiguration.airDCPPState) &&
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
) {
await airDCPPConfiguration.airDCPPState.socket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
// download tick listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
dispatch({
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
downloadProgressData,
});
},
);
// download complete listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
dispatch({
type: LS_SINGLE_IMPORT,
meta: { remote: true },
data: bundleData,
});
count += 1;
}
}
},
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle changes",
);
console.log(
"[AirDCPP]: Listener registered - listening to transfer completion",
);
}
};
initializeAirDCPPEventListeners();
}, [airDCPPConfiguration]);
return <></>;
};
export const App = (): ReactElement => { export const App = (): ReactElement => {
return ( return (
<> <AirDCPPSocketContextProvider>
<Navbar2 /> <div>
<Outlet /> <AirDCPPSocketComponent />
</> <Navbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/import" element={<Import path={"./comics"} />} />
<Route
path="/library"
element={<TabulatedContentContainer category="library" />}
/>
<Route path="/library-grid" element={<LibraryGrid />} />
<Route path="/downloads" element={<Downloads data={{}} />} />
<Route path="/search" element={<Search />} />
<Route
path={"/comic/details/:comicObjectId"}
element={<ComicDetailContainer />}
/>
<Route
path={"/volume/details/:comicObjectId"}
element={<VolumeDetail />}
/>
<Route path="/settings" element={<Settings />} />
<Route
path="/pull-list/all"
element={<TabulatedContentContainer category="pullList" />}
/>
<Route
path="/wanted/all"
element={<TabulatedContentContainer category="wanted" />}
/>
<Route
path="/volumes/all"
element={<TabulatedContentContainer category="volumes" />}
/>
</Routes>
</div>
</AirDCPPSocketContextProvider>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +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: ["comicBookMetadata"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
); );
const dispatch = useDispatch();
const { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
) : null;
}; };

View File

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

View File

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

View File

@@ -1,96 +0,0 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../../actions/fileops.actions";
/**
* Component for performing search against ComicVine
*
* @component
* @example
* return (
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = (data) => {
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
number: value.issueNumber,
subtitle: "",
year: value.issueYear,
},
};
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
};
const MyForm = () => (
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="flex items-center">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<label className="block py-1">Issue Name</label>
<Field name="issueName">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-full rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type the issue name"
/>
)}
</Field>
<div className="flex flex-row gap-4">
<div>
<label className="block py-1">Number</label>
<Field name="issueNumber">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-14 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="#"
/>
)}
</Field>
</div>
<div>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
</div>
<div className="flex justify-end mt-5">
<button
type="submit"
className="flex h-10 sm:mt-3 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
>
Search
</button>
</div>
</div>
</form>
)}
/>
);
return <MyForm />;
};
export default ComicVineSearchForm;

View File

@@ -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,103 +1,76 @@
import React, { useEffect, useContext, ReactElement, useState } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings"; import { RootState } from "threetwo-ui-typings";
import { isEmpty, map } from "lodash"; import { isEmpty, isNil, map } from "lodash";
import 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 { useQuery } from "@tanstack/react-query"; import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
interface IDownloadsPanelProps { interface IDownloadsPanelProps {
key: number; data: any;
comicObjectId: string;
} }
export const DownloadsPanel = ( export const DownloadsPanel = (
props: IDownloadsPanelProps, props: IDownloadsPanelProps,
): ReactElement | null => { ): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const bundles = useSelector((state: RootState) => {
const [bundles, setBundles] = useState([]); return state.airdcpp.bundles;
const { airDCPPSocketInstance } = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
})),
);
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
const { data: comicObject, isSuccess } = useQuery({
queryKey: ["bundles"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
id: `${comicObjectId}`,
},
}),
}); });
const getBundles = async (comicObject) => { // AirDCPP Socket initialization
if (comicObject?.data.acquisition.directconnect) { const userSettings = useSelector((state: RootState) => state.settings.data);
const filteredBundles = const airDCPPConfiguration = useContext(AirDCPPSocketContext);
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
},
);
return await Promise.all(filteredBundles);
}
};
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => { useEffect(() => {
getBundles(comicObject).then((result) => { try {
setBundles(result); if (!isEmpty(userSettings)) {
}); dispatch(
}, [comicObject]); getBundlesForComic(props.comicObjectId, socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
}
} catch (error) {
throw new Error(error);
}
}, [dispatch, airDCPPConfiguration]);
const Bundles = (props) => { const Bundles = (props) => {
return ( return !isEmpty(props.data) ? (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200"> <div className="column is-full">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md"> <table className="table is-striped">
<thead> <thead>
<tr> <tr>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Filename</th>
Filename <th>Size</th>
</th> <th>Download Time</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Bundle ID</th>
Size
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Time
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody>
{map(props.data, (bundle) => ( {map(props.data, (bundle) => (
<tr key={bundle.id} className="text-sm"> <tr key={bundle.id}>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td>
<h5>{ellipsize(bundle.name, 58)}</h5> <h5>{ellipsize(bundle.name, 58)}</h5>
<span className="text-xs"> <span className="is-size-7">{bundle.target}</span>
{ellipsize(bundle.target, 88)}
</span>
</td> </td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td>{prettyBytes(bundle.size)}</td>
{prettyBytes(bundle.size)} <td>
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{dayjs {dayjs
.unix(bundle.time_finished) .unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")} .format("h:mm on ddd, D MMM, YYYY")}
</td> </td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> <td>
<span className="tag is-warning">{bundle.id}</span> <span className="tag is-warning">{bundle.id}</span>
</td> </td>
</tr> </tr>
@@ -105,15 +78,29 @@ export const DownloadsPanel = (
</tbody> </tbody>
</table> </table>
</div> </div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
); );
}; };
return ( return !isNil(props.data) ? (
<div className="columns is-multiline"> <>
{!isEmpty(airDCPPSocketInstance) && !isEmpty(bundles) && ( <div className="columns is-multiline">
<Bundles data={bundles} /> {!isEmpty(socket) ? (
)} <Bundles data={bundles} />
</div> ) : (
); <div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
)}
</div>
</>
) : null;
}; };
export default DownloadsPanel; export default DownloadsPanel;

View File

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

View File

@@ -1,137 +0,0 @@
import React from "react";
import { isNil, map } from "lodash";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
interface MatchResultProps {
matchData: any;
comicObjectId: string;
}
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
};
export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (match, comicObjectId) => {
return await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
method: "POST",
data: {
match,
comicObjectId,
},
});
};
return (
<>
<span className="flex items-center mt-6">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
ComicVine Matches
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
issueDescription = convert(match.description, {
baseElements: {
selectors: ["p"],
},
});
}
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<img
className="rounded-md"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
{/* score */}
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.score, 10)}
</span>
</span>
</div>
<span className="flex flex-row gap-2 mb-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.issue_number, 10)}
</span>
</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--calendar-mark-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
Cover Date: {match.cover_date}
</span>
</span>
</span>
<div className="text-sm">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="flex flex-row gap-2 my-4 ml-10">
<div className="">
<img
src={match.volumeInformation.results.image.icon_url}
className="rounded-md w-full"
onError={handleBrokenImage}
/>
</div>
<div className="">
<span>{match.volume.name}</span>
<div className="text-sm">
<p>
Total Issues:
{match.volumeInformation.results.count_of_issues}
</p>
<p>
Published by{" "}
{match.volumeInformation.results.publisher.name}
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="text-md">Apply Match</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
</span>
</button>
</div>
</div>
);
})}
</>
);
};
export default MatchResult;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
import React, { useCallback, ReactElement, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Form, Field } from "react-final-form";
import { PROWLARR_SERVICE_BASE_URI } from "../../constants/endpoints";
export const TorrentSearchPanel = (props): ReactElement => {
const [prowlarrSettingsData, setProwlarrSettingsData] = useState({});
const { data } = useQuery({
queryFn: async () =>
axios({
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
method: "POST",
data: {
port: "9696",
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
offset: 0,
categories: [7030],
query: "the darkness",
host: "localhost",
limit: 100,
type: "search",
indexerIds: [2],
},
}),
queryKey: ["prowlarrSettingsData"],
});
console.log(data?.data);
return (
<>
<div className="mt-5">
<Form
onSubmit={() => {}}
initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<input
{...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Enter a search term"
/>
<button
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
<div className="flex flex-row">
Search Indexer
<div className="h-5 w-5 ml-1">
<i className="h-6 w-6 icon-[solar--magnet-bold-duotone]" />
</div>
</div>
</button>
</div>
</div>
);
}}
</Field>
</form>
)}
/>
</div>
</>
);
};
export default TorrentSearchPanel;

View File

@@ -0,0 +1,126 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../actions/fileops.actions";
import { useDispatch } from "react-redux";
/**
* Component for performing search against ComicVine
*
* @component
* @example
* return (
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = (data) => {
const dispatch = useDispatch();
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
number: value.issueNumber,
subtitle: "",
year: value.issueYear,
},
};
dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
};
const MyForm = () => (
<Form
onSubmit={onSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="field is-normal">
<label className="label mb-2 is-size-5">Search Manually</label>
</span>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueName">
{(props) => (
<p className="control is-expanded has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue name"
/>
<span className="icon is-small is-left">
<i className="fas fa-journal-whills"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueNumber">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
</div>
</div>
</form>
)}
/>
);
return <MyForm />;
};
export default ComicVineSearchForm;

View File

@@ -1,79 +1,97 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import ZeroState from "./ZeroState"; import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported"; import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList"; import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups"; import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics"; import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList"; import { PullList } from "./PullList";
import { useQuery } from "@tanstack/react-query"; import {
import axios from "axios"; fetchVolumeGroups,
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints"; getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil } from "lodash";
export const Dashboard = (): ReactElement => { export const Dashboard = (): ReactElement => {
const { data: recentComics } = useQuery({ const dispatch = useDispatch();
queryFn: async () => useEffect(() => {
await axios({ dispatch(fetchVolumeGroups());
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`, dispatch(
method: "POST", getComicBooks({
data: { paginationOptions: {
paginationOptions: { page: 0,
page: 0, limit: 5,
limit: 5, sort: { updatedAt: "-1" },
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}, },
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}), }),
queryKey: ["recentComics"], );
}); dispatch(
getComicBooks({
const { data: wantedComics } = useQuery({ paginationOptions: {
queryFn: async () => page: 0,
await axios({ limit: 5,
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`, sort: { updatedAt: "-1" },
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
}, },
predicate: { "acquisition.source.wanted": true },
comicStatus: "wanted",
}), }),
queryKey: ["wantedComics"], );
}); dispatch(getLibraryStatistics());
const { data: volumeGroups } = useQuery({ }, []);
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
}),
queryKey: ["volumeGroups"],
});
const { data: statistics } = useQuery({ const recentComics = useSelector(
queryFn: async () => (state: RootState) => state.fileOps.recentComics
await axios({ );
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`, const wantedComics = useSelector(
method: "GET", (state: RootState) => state.fileOps.wantedComics,
}), );
queryKey: ["libraryStatistics"], const volumeGroups = useSelector(
}); (state: RootState) => state.fileOps.comicVolumeGroups,
);
const libraryStatistics = useSelector(
(state: RootState) => state.comicInfo.libraryStatistics,
);
return ( return (
<div className="container mx-auto max-w-full"> <div className="container">
<PullList /> <section className="section">
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />} <h1 className="title">Dashboard</h1>
{/* Wanted comics */}
<WantedComicsList comics={wantedComics?.data?.docs} /> {!isEmpty(recentComics) ? (
{/* Library Statistics */} <>
{statistics && <LibraryStatistics stats={statistics?.data} />} {/* Pull List */}
{/* Volume groups */} <PullList issues={recentComics} />
<VolumeGroups volumeGroups={volumeGroups?.data} />
{/* Stats */}
{!isEmpty(libraryStatistics) && (
<LibraryStatistics stats={libraryStatistics} />
)}
{/* Wanted comics */}
{!isEmpty(wantedComics) && (
<WantedComicsList comics={wantedComics} />
)}
{/* Recent imports */}
<RecentlyImported comicBookCovers={recentComics} />
{/* Volumes */}
{!isEmpty(volumeGroups) && (
<VolumeGroups volumeGroups={volumeGroups} />
)}
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
}
/>
)}
</section>
</div> </div>
); );
}; };
export default Dashboard; export default Dashboard;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
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 +11,16 @@ 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(() => {
@@ -37,26 +37,18 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
useEffect(() => { useEffect(() => {
if (!isUndefined(issueBundles)) { if (!isUndefined(issueBundles)) {
const foo = issueBundles.data.map((bundle) => { const foo = issueBundles.data.map((bundle) => {
const { const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
rawFileDetails,
inferredMetadata,
acquisition: {
directconnect: { downloads },
},
sourcedMetadata: { locg, comicvine },
} = bundle;
const { issueName, url } = determineCoverFile({ const { issueName, url } = determineCoverFile({
rawFileDetails, rawFileDetails, comicvine, locg,
comicvine,
locg,
}); });
return { ...bundle, issueName, url }; return { ...bundle, issueName, url }
});
})
setBundles(foo); setBundles(foo);
} }
}, [issueBundles]); }, [issueBundles])
return !isNil(bundles) ? ( return !isNil(bundles) ?
<div className="container"> <div className="container">
<section className="section"> <section className="section">
<h1 className="title">Downloads</h1> <h1 className="title">Downloads</h1>
@@ -64,59 +56,45 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
<div className="column is-half"> <div className="column is-half">
{bundles.map((bundle, idx) => { {bundles.map((bundle, idx) => {
console.log(bundle); console.log(bundle);
return ( return <div key={idx}>
<div key={idx}> <MetadataPanel
<MetadataPanel data={bundle}
data={bundle} imageStyle={{ maxWidth: 80 }}
imageStyle={{ maxWidth: 80 }} titleStyle={{ fontSize: "0.8rem" }}
titleStyle={{ fontSize: "0.8rem" }} tagsStyle={{ fontSize: "0.7rem" }}
tagsStyle={{ fontSize: "0.7rem" }} containerStyle={{
containerStyle={{ maxWidth: 400,
maxWidth: 400, padding: 0,
padding: 0, margin: "0 0 8px 0",
margin: "0 0 8px 0", }} />
}}
/>
<table className="table is-size-7"> <table className="table is-size-7">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Size</th> <th>Size</th>
<th>Type</th> <th>Type</th>
<th>Bundle ID</th> <th>Bundle ID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bundle.acquisition.directconnect.downloads.map( {bundle.acquisition.directconnect.downloads.map((bundle, idx) => {
(bundle, idx) => { return (<tr key={idx}>
return ( <td>{bundle.name}</td>
<tr key={idx}> <td>{bundle.size}</td>
<td>{bundle.name}</td> <td>{bundle.type.str}</td>
<td>{bundle.size}</td> <td><span className="tag is-warning">{bundle.bundleId}</span></td>
<td>{bundle.type.str}</td> </tr>)
<td> })}
<span className="tag is-warning"> </tbody>
{bundle.bundleId} </table>
</span> {/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</td> </div>
</tr>
);
},
)}
</tbody>
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</div>
);
})} })}
</div> </div>
</div> </div>
</section> </section>
</div> </div> : <div>There are no downloads.</div>;
) : (
<div>There are no downloads.</div>
);
}; };
export default Downloads; export default Downloads;

View File

@@ -1,7 +1,7 @@
import { debounce, isEmpty, map } from "lodash"; import { debounce, isEmpty, map } from "lodash";
import React, { ReactElement, useCallback, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import Card from "../shared/Carda"; import Card from "../Carda";
import { searchIssue } from "../../actions/fileops.actions"; import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel"; import MetadataPanel from "../shared/MetadataPanel";

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import { useDispatch, useSelector } from "react-redux";
import { getComicBooks } from "../../actions/fileops.actions"; import { getComicBooks } from "../../actions/fileops.actions";
import { isNil, isEmpty, isUndefined } from "lodash"; import { isNil, isEmpty, isUndefined } from "lodash";
import Masonry from "react-masonry-css"; import Masonry from "react-masonry-css";
import Card from "../shared/Carda"; import Card from "../Carda";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints"; import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
@@ -73,7 +73,7 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
<div className="content is-flex is-flex-direction-row"> <div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && ( {!isEmpty(sourcedMetadata.comicvine) && (
<span className="icon cv-icon is-small"> <span className="icon cv-icon is-small">
<img src="/src/client/assets/img/cvlogo.svg" /> <img src="/dist/img/cvlogo.svg" />
</span> </span>
)} )}
{isNil(rawFileDetails) && ( {isNil(rawFileDetails) && (

View File

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

View File

@@ -0,0 +1,126 @@
import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../actions/comicinfo.actions";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
interface MatchResultProps {
matchData: any;
comicObjectId: string;
}
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
};
export const MatchResult = (props: MatchResultProps) => {
const dispatch = useDispatch();
const applyCVMatch = useCallback(
(match, comicObjectId) => {
dispatch(applyComicVineMatch(match, comicObjectId));
},
[dispatch],
);
return (
<>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
issueDescription = convert(match.description, {
baseElements: {
selectors: ["p"],
},
});
}
return (
<div className="search-result mb-4" key={idx}>
<div className="columns">
<div className="column is-one-fifth">
<img
className="cover-image"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div className="search-result-details column">
<div className="is-size-5">{match.name}</div>
<div className="field is-grouped is-grouped-multiline mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary">
{match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
</div>
<div className="is-size-7">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="vertical-line"></div>
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img
src={match.volumeInformation.results.image.icon_url}
className="cover-image"
onError={handleBrokenImage}
/>
</div>
<div className="column">
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Total Issues</span>
<span className="tag is-warning">
{match.volumeInformation.results.count_of_issues}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<button
className="button is-normal is-outlined is-primary is-light is-pulled-right"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="icon is-size-5">
<i className="fas fa-clipboard-check"></i>
</span>
<span>Apply Match</span>
</button>
</div>
</div>
</div>
);
})}
</>
);
};
export default MatchResult;

View File

@@ -1,46 +1,29 @@
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 { useSelector } from "react-redux";
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 { const downloadProgressTick = useSelector(
airDCPPSocketConnected, (state: RootState) => state.airdcpp.downloadProgressData,
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,
// );
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,7 +69,7 @@ const Navbar: React.FunctionComponent = (props) => {
Downloads Downloads
</Link> </Link>
{/* <SearchBar /> */} <SearchBar />
<Link to="/search" className="navbar-item"> <Link to="/search" className="navbar-item">
Search ComicVine Search ComicVine
@@ -99,68 +82,30 @@ const Navbar: React.FunctionComponent = (props) => {
<div className="navbar-item has-dropdown is-hoverable"> <div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless"> <a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i> <i className="fa-solid fa-download"></i>
{!isEmpty(airDCPPDownloadTick) && ( {downloadProgressTick && <div className="pulsating-circle"></div>}
<div className="pulsating-circle"></div>
)}
</a> </a>
{!isEmpty(airDCPPDownloadTick) ? ( {!isUndefined(downloadProgressTick) ? (
<div className="navbar-dropdown is-right is-boxed"> <div className="navbar-dropdown is-right">
<a className="navbar-item"> <a className="navbar-item">
<DownloadProgressTick data={airDCPPDownloadTick} /> <DownloadProgressTick data={downloadProgressTick} />
</a> </a> </div>
</div>
) : null} ) : null}
</div> </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"> <div className="navbar-item has-dropdown is-hoverable">
{!isUndefined(airDCPPSessionInformation.user) ? ( {airDCPPSocketConnectionStatus ? (
<> <>
<a className="navbar-link is-arrowless has-text-success"> <a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i> <i className="fa-solid fa-bolt"></i>
</a> </a>
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed"> <div className="navbar-dropdown mt-3 pt-4 pr-2 pl-2 is-right airdcpp-status is-boxed">
{/* AirDC++ Session Information */} {/* AirDC++ Session Information */}
<p> <p>
Last login was{" "} Last login was{" "}
<span className="tag"> <span className="tag">
{format( {format(
fromUnixTime( fromUnixTime(airDCPPSessionInfo.user.last_login),
airDCPPSessionInformation?.user.last_login,
),
"dd MMMM, yyyy", "dd MMMM, yyyy",
)} )}
</span> </span>
@@ -168,17 +113,19 @@ const Navbar: React.FunctionComponent = (props) => {
<hr className="navbar-divider" /> <hr className="navbar-divider" />
<p> <p>
<span className="tag has-text-success"> <span className="tag has-text-success">
{airDCPPSessionInformation.user.username} {airDCPPSessionInfo.user.username}
</span> </span>
connected to{" "} connected to{" "}
<span className="tag has-text-success"> <span className="tag has-text-success">
{airDCPPSessionInformation.system_info.client_version} {airDCPPSessionInfo.system_info.client_version}
</span>{" "} </span>{" "}
with session ID{" "} with session ID{" "}
<span className="tag has-text-success"> <span className="tag has-text-success">
{airDCPPSessionInformation.session_id} {airDCPPSessionInfo.session_id}
</span> </span>
</p> </p>
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
</div> </div>
</> </>
) : ( ) : (
@@ -187,7 +134,9 @@ const Navbar: React.FunctionComponent = (props) => {
<i className="fa-solid fa-bolt"></i> <i className="fa-solid fa-bolt"></i>
</a> </a>
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed"> <div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre> <pre>
{JSON.stringify(socketDisconnectionReason, null, 2)}
</pre>
</div> </div>
</> </>
)} )}

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 Card from "../shared/Carda"; import { useDispatch, useSelector } from "react-redux";
import Card from "../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: "2022-11-15",
// pageSize: "15", pageSize: "15",
// currentPage: "1", currentPage: "1",
// }), }),
// ); );
}, []); }, []);
const nextPageHandler = () => {}; const nextPageHandler = () => {};
const previousPageHandler = () => {}; const previousPageHandler = () => {};
@@ -107,15 +109,15 @@ export const PullList = (): ReactElement => {
{!isNil(pullListComics) && ( {!isNil(pullListComics) && (
<div> <div>
<div className="library"> <div className="library">
<T2Table <T2Table
sourceData={pullListComics} sourceData={pullListComics}
columns={columnData} columns={columnData}
totalPages={pullListComics.length} totalPages={pullListComics.length}
paginationHandlers={{ paginationHandlers={{
nextPage: nextPageHandler, nextPage: nextPageHandler,
previousPage: previousPageHandler, previousPage: previousPageHandler,
}} }}
/> />
</div> </div>
</div> </div>
)} )}

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
import React, { ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";
import { getServiceStatus } from "../../actions/fileops.actions";
export const ServiceStatuses = (): ReactElement => {
const serviceStatus = useSelector(
(state: RootState) => state.fileOps.libraryServiceStatus,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getServiceStatus());
}, []);
return (
<div className="is-clearfix">
<div className="mt-4">
<h3 className="title">Core Services</h3>
<h6 className="subtitle has-text-grey-light">
Statuses for core services
</h6>
</div>
<pre>{JSON.stringify(serviceStatus, null, 2)}</pre>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -2,7 +2,7 @@ import { isArray, map } from "lodash";
import React, { useEffect, ReactElement } from "react"; import React, { useEffect, ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions"; import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions";
import { Card } from "../shared/Carda"; import { Card } from "../Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints"; import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils"; import { escapePoundSymbol } from "../../shared/utils/formatting.utils";

View File

@@ -9,7 +9,7 @@ import {
} from "../../actions/comicinfo.actions"; } from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches"; import PotentialLibraryMatches from "./PotentialLibraryMatches";
import Masonry from "react-masonry-css"; import Masonry from "react-masonry-css";
import { Card } from "../shared/Carda"; import { Card } from "../Carda";
import SlidingPane from "react-sliding-pane"; import SlidingPane from "react-sliding-pane";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";

View File

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

View File

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

View File

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

View File

@@ -1,173 +0,0 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { isEmpty, isNil } from "lodash";
interface ICardProps {
orientation: string;
imageUrl?: string;
hasDetails?: boolean;
title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike;
borderColorClass?: string;
backgroundColor?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: PropTypes.object;
imageStyle?: PropTypes.object;
}
const renderCard = (props: ICardProps): ReactElement => {
switch (props.orientation) {
case "horizontal":
return (
<div className="card-container">
<div className="card generic-card">
<div className="is-horizontal">
<div className="card-image">
<img
style={props.imageStyle}
src={props.imageUrl}
alt="Placeholder image"
className="cropped-image"
/>
</div>
{props.hasDetails && (
<div className="card-content">{props.children}</div>
)}
</div>
</div>
</div>
);
case "vertical":
return (
<div onClick={props.onClick}>
<div className="generic-card" style={props.cardContainerStyle}>
<div
className={
!isNil(props.borderColorClass)
? `${props.borderColorClass}`
: ""
}
>
<div
className={
props.hasDetails
? "partial-rounded-card-image"
: "rounded-card-image"
}
>
<figure>
<img
src={props.imageUrl}
style={props.imageStyle}
alt="Placeholder image"
/>
</figure>
</div>
{props.hasDetails && (
<div
className="card-content"
style={{ backgroundColor: props.backgroundColor }}
>
{!isNil(props.title) ? (
<div className="card-title is-size-8 is-family-secondary">
{props.title}
</div>
) : null}
{props.children}
</div>
)}
</div>
</div>
</div>
);
case "vertical-2":
return (
<div className="block rounded-md w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);
case "horizontal-small":
return (
<>
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} className="object-cover h-20 w-20" />
</div>
{/* details */}
<div className="w-fit h-fit pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
</div>
</div>
</>
);
case "horizontal-medium":
return (
<>
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} />
</div>
{/* details */}
<div className="pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
{props.hasDetails && <>{props.children}</>}
</div>
</div>
</>
);
case "cover-only":
return (
<>
{/* thumbnail */}
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
</>
);
case "card-with-info-panel":
return (
<>
<div className="flex flex-row">
{/* thumbnail */}
<div className="rounded-md overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
{/* myata-dyata */}
</div>
</>
);
default:
return <></>;
}
};
export const Card = React.memo(
(props: ICardProps): ReactElement => renderCard(props),
);
export default Card;

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import React from "react";
export const ErrorPage = () => {
return <>Error has been encountered.</>;
};

View File

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

View File

@@ -2,7 +2,7 @@ import React, { ReactElement } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { Card } from "../shared/Carda"; import { Card } from "../Carda";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash"; import { find, isUndefined } from "lodash";
@@ -16,7 +16,6 @@ interface IMetadatPanelProps {
containerStyle: any; containerStyle: any;
} }
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
console.log(props);
const { const {
rawFileDetails, rawFileDetails,
inferredMetadata, inferredMetadata,
@@ -32,62 +31,61 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "rawFileDetails", name: "rawFileDetails",
content: () => ( content: () => (
<dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg"> <dl>
<dt> <dt>
<p className="text-lg">{issueName}</p> <h6
className="name has-text-weight-medium mb-1"
style={props.titleStyle}
>
{issueName}
</h6>
</dt> </dt>
<dd className="text-sm"> <dd className="is-size-7">
is a part of{" "} Is a part of{" "}
<span className="underline"> <span className="has-text-weight-semibold">
{inferredMetadata.issue.name} {inferredMetadata.issue.name}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span> </span>
</dd> </dd>
{/* Issue number */} <dd className="is-size-7 mt-2">
{inferredMetadata.issue.number && ( <div className="field is-grouped is-grouped-multiline">
<dd className="my-2"> <div className="control">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="tags">
<span className="pr-1 pt-1"> <span
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i> className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.extension}
</span>
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.mimeType}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span> </span>
<span className="text-md text-slate-900 dark:text-slate-900"> </div>
{inferredMetadata.issue.number} <div className="control">
</span> {inferredMetadata.issue.number && (
</span> <div className="tags has-addons">
</dd> <span className="tag is-light" style={props.tagsStyle}>
)} Issue #
<dd className="flex flex-row gap-2 w-max"> </span>
{/* File extension */} <span className="tag is-warning" style={props.tagsStyle}>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> {inferredMetadata.issue.number}
<span className="pr-1 pt-1"> </span>
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i> </div>
</span> )}
</div>
<span className="text-md text-slate-500 dark:text-slate-900"> </div>
{rawFileDetails.mimeType}
</span>
</span>
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span>
)}
</dd> </dd>
</dl> </dl>
), ),
@@ -115,6 +113,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span> </span>
</span> </span>
</dd> </dd>
<dd className="is-size-7"> <dd className="is-size-7">
<span> <span>
{ellipsize( {ellipsize(
@@ -127,13 +126,42 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)} )}
</span> </span>
</dd> </dd>
<dd className="is-size-7 mt-2"> <dd className="is-size-7 mt-2">
<span className="my-3 mx-2"> <div className="field is-grouped is-grouped-multiline">
{comicvine.volumeInformation.start_year} <div className="control">
</span> <span className="tags">
{comicvine.volumeInformation.count_of_issues} <span
ComicVine ID className="tag is-success is-light has-text-weight-semibold"
{comicvine.id} style={props.tagsStyle}
>
{comicvine.volumeInformation.start_year}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{comicvine.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
</dd> </dd>
</dl> </dl>
), ),
@@ -180,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,129 +0,0 @@
import React, { ReactElement, useState } from "react";
import { Link } from "react-router-dom";
import { useDarkMode } from "../../hooks/useDarkMode";
export const Navbar2 = (): ReactElement => {
const [colorTheme, setTheme] = useDarkMode();
const [darkMode, setDarkMode] = useState(false);
const toggleDarkMode = (checked) => {
setTheme(colorTheme);
setDarkMode(!darkMode);
};
return (
<header className="bg-white dark:bg-gray-900 gap-8 px-5 py-5 h-18 border-b-2 border-gray-300 dark:border-slate-200">
{/* Logo */}
<div className="mx-auto flex">
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
{/* Main Navigation */}
<div className="flex flex-1 items-center justify-end md:justify-between">
<nav
aria-label="ThreeTwo Main Navigation"
className="hidden md:block"
>
<ul className="flex items-center gap-6 text-md">
<li>
<Link
to="/dashboard"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Dashboard
</Link>
</li>
<li>
<Link
to="/import"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Import
</Link>
</li>
<li>
<Link
to="/library"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Library
</Link>
</li>
<li>
<Link
to="/volumes"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Volumes
</Link>
</li>
<li>
<a
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Downloads
</a>
</li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</ul>
</nav>
{/* Right-most Nav */}
<div className="flex items-center gap-4">
<ul className="flex items-center gap-6 text-md">
{/* Settings Icon and text */}
<li>
<Link
to="/settings"
className="flex items-center space-x-1 text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
<span className="w-5 h-5">
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
</span>
<span>Settings</span>
</Link>
</li>
<li>
{/* Light/Dark Mode toggle */}
<div className="flex items-center space-x-2">
<span className="text-gray-600 dark:text-white">Light</span>
<label
htmlFor="toggle"
className="relative inline-flex items-center"
>
<input
type="checkbox"
id="toggle"
className="sr-only"
checked={darkMode}
onChange={toggleDarkMode}
/>
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
<span
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
darkMode ? "translate-x-4" : ""
}`}
></span>
</label>
<span className="text-gray-600 dark:text-white">Dark</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</header>
);
};

View File

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

View File

@@ -14,6 +14,7 @@ export const IMS_RAW_IMPORT_FAILED = "IMS_RAW_IMPORT_FAILED";
export const LS_IMPORT_CALL_IN_PROGRESS = "LS_IMPORT_CALL_IN_PROGRESS"; export const LS_IMPORT_CALL_IN_PROGRESS = "LS_IMPORT_CALL_IN_PROGRESS";
// Library import bull mq queue control // Library import bull mq queue control
export const LS_TOGGLE_IMPORT_QUEUE = "LS_TOGGLE_IMPORT_QUEUE"; export const LS_TOGGLE_IMPORT_QUEUE = "LS_TOGGLE_IMPORT_QUEUE";
export const LS_QUEUE_DRAINED = "LS_QUEUE_DRAINED";
// ComicVine Metadata // ComicVine Metadata
export const IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS = export const IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS =
@@ -50,12 +51,11 @@ export const IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS =
"IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS"; "IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS";
export const IMS_COMIC_BOOK_GROUPS_CALL_FAILED = export const IMS_COMIC_BOOK_GROUPS_CALL_FAILED =
"IMS_COMIC_BOOK_GROUPS_CALL_FAILED"; "IMS_COMIC_BOOK_GROUPS_CALL_FAILED";
export const VOLUMES_FETCHED = "VOLUMES_FETCHED"; export const VOLUMES_FETCHED="VOLUMES_FETCHED";
// search results from the Search service // search results from the Search service
export const SS_SEARCH_RESULTS_FETCHED = "SS_SEARCH_RESULTS_FETCHED"; export const SS_SEARCH_RESULTS_FETCHED = "SS_SEARCH_RESULTS_FETCHED";
export const SS_SEARCH_RESULTS_FETCHED_SPECIAL = export const SS_SEARCH_RESULTS_FETCHED_SPECIAL = "SS_SEARCH_RESULTS_FETCHED_SPECIAL";
"SS_SEARCH_RESULTS_FETCHED_SPECIAL";
export const SS_SEARCH_IN_PROGRESS = "SS_SEARCH_IN_PROGRESS"; export const SS_SEARCH_IN_PROGRESS = "SS_SEARCH_IN_PROGRESS";
export const SS_SEARCH_FAILED = "SS_SEARCH_FAILED"; export const SS_SEARCH_FAILED = "SS_SEARCH_FAILED";
@@ -105,7 +105,7 @@ export const AIRDCPP_SEARCH_RESULTS_UPDATED = "AIRDCPP_SEARCH_RESULTS_UPDATED";
export const AIRDCPP_SEARCH_COMPLETE = "AIRDCPP_SEARCH_COMPLETE"; export const AIRDCPP_SEARCH_COMPLETE = "AIRDCPP_SEARCH_COMPLETE";
// AirDC++ related library query for issues with bundles associated with them // AirDC++ related library query for issues with bundles associated with them
export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES"; export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES";
export const AIRDCPP_HUB_SEARCHES_SENT = "AIRDCPP_HUB_SEARCHES_SENT"; export const AIRDCPP_HUB_SEARCHES_SENT = "AIRDCPP_HUB_SEARCHES_SENT";
export const AIRDCPP_RESULT_DOWNLOAD_INITIATED = export const AIRDCPP_RESULT_DOWNLOAD_INITIATED =
@@ -124,17 +124,10 @@ export const AIRDCPP_TRANSFERS_FETCHED = "AIRDCPP_TRANSFERS_FETCHED";
// Comics marked as "wanted" // Comics marked as "wanted"
export const WANTED_COMICS_FETCHED = "WANTED_COMICS_FETCHED"; export const WANTED_COMICS_FETCHED = "WANTED_COMICS_FETCHED";
// LIBRARY Service import queue-related action types // LIBRARY SOCKET ENDPOINT
export const LS_IMPORT = "LS_IMPORT"; export const LS_IMPORT = "LS_IMPORT";
export const LS_COVER_EXTRACTED = "LS_COVER_EXTRACTED"; export const LS_COVER_EXTRACTED = "LS_COVER_EXTRACTED";
export const LS_COVER_EXTRACTION_FAILED = "LS_COVER_EXTRACTION_FAILED";
export const LS_COMIC_ADDED = "LS_COMIC_ADDED"; export const LS_COMIC_ADDED = "LS_COMIC_ADDED";
export const LS_IMPORT_QUEUE_DRAINED = "LS_IMPORT_QUEUE_DRAINED";
export const LS_SET_QUEUE_STATUS = "LS_SET_QUEUE_STATUS";
export const RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION =
"RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION";
export const LS_IMPORT_JOB_STATISTICS_FETCHED =
"LS_IMPORT_JOB_STATISTICS_FETCHED";
// Settings // Settings
export const SETTINGS_CALL_IN_PROGRESS = "SETTINGS_CALL_IN_PROGRESS"; export const SETTINGS_CALL_IN_PROGRESS = "SETTINGS_CALL_IN_PROGRESS";
@@ -143,12 +136,7 @@ export const SETTINGS_CALL_FAILED = "SETTINGS_CALL_FAILED";
export const SETTINGS_OBJECT_DELETED = "SETTINGS_OBJECT_DELETED"; export const SETTINGS_OBJECT_DELETED = "SETTINGS_OBJECT_DELETED";
export const SETTINGS_DB_FLUSH_SUCCESS = "SETTINGS_DB_FLUSH_SUCCESS"; export const SETTINGS_DB_FLUSH_SUCCESS = "SETTINGS_DB_FLUSH_SUCCESS";
export const SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED = "SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED";
// Metron Metadata // Metron Metadata
export const METRON_DATA_FETCH_SUCCESS = "METRON_DATA_FETCH_SUCCESS"; export const METRON_DATA_FETCH_SUCCESS = "METRON_DATA_FETCH_SUCCESS";
export const METRON_DATA_FETCH_IN_PROGRESS = "METRON_DATA_FETCH_IN_PROGRESS"; export const METRON_DATA_FETCH_IN_PROGRESS = "METRON_DATA_FETCH_IN_PROGRESS";
export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR"; export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR";
// service health statuses
export const LIBRARY_SERVICE_HEALTH = "LIBRARY_SERVICE_HEALTH";

View File

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

View File

@@ -11,89 +11,68 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
export const CORS_PROXY_SERVER_URI = hostURIBuilder({ export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "8050", port: "8050",
apiPath: "/", apiPath: "/",
}); });
export const COMICVINE_SERVICE_URI = hostURIBuilder({ export const COMICVINE_SERVICE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3080", port: "3080",
apiPath: "/api/comicvine", apiPath: "/api/comicvine",
}); });
export const METRON_SERVICE_URI = hostURIBuilder({ export const METRON_SERVICE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3080", port: "3080",
apiPath: "/api/metron", apiPath: "/api/metron",
}); });
export const API_BASE_URI = hostURIBuilder({ export const API_BASE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "8050", port: "8050",
apiPath: "/api", apiPath: "/api",
}); });
export const LIBRARY_SERVICE_HOST = hostURIBuilder({ export const LIBRARY_SERVICE_HOST = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000", port: "3000",
apiPath: ``, apiPath: ``,
}); });
export const LIBRARY_SERVICE_BASE_URI = hostURIBuilder({ export const LIBRARY_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000", port: "3000",
apiPath: "/api/library", apiPath: "/api/library",
}); });
export const SEARCH_SERVICE_BASE_URI = hostURIBuilder({ export const SEARCH_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000", port: "3000",
apiPath: "/api/search", apiPath: "/api/search",
}); });
export const SETTINGS_SERVICE_BASE_URI = hostURIBuilder({ export const SETTINGS_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000", port: "3000",
apiPath: "/api/settings", apiPath: "/api/settings",
}); });
export const IMAGETRANSFORMATION_SERVICE_BASE_URI = hostURIBuilder({ export const IMAGETRANSFORMATION_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http", protocol: "http",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000", port: "3000",
apiPath: "/api/imagetransformation", apiPath: "/api/imagetransformation",
}); });
export const SOCKET_BASE_URI = hostURIBuilder({ export const SOCKET_BASE_URI = hostURIBuilder({
protocol: "ws", protocol: "ws",
host: import.meta.env.VITE_UNDERLYING_HOSTNAME || "localhost", host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3001", port: "3001",
apiPath: `/`, apiPath: `/`,
}); });
export const JOB_QUEUE_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/jobqueue`,
});
export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/qbittorrent`,
});
export const PROWLARR_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/prowlarr`,
});

View File

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

View File

@@ -37,34 +37,6 @@
"displayName": "Additional Configuration" "displayName": "Additional Configuration"
} }
] ]
},
{
"id": "qbittorrent",
"displayName": "qBittorrent",
"children": [
{
"id": "qbt-connection",
"displayName": "Connection"
},
{
"id": "qbt-additional-configuration",
"displayName": "qbt-configuration"
}
]
},
{
"id": "prowlarr",
"displayName": "Prowlarr",
"children": [
{
"id": "prwlr-connection",
"displayName": "Connection"
},
{
"id": "prowlarr-additional-configuration",
"displayName": "prowlarr-configuration"
}
]
} }
] ]
}, },
@@ -85,21 +57,6 @@
}, },
{ {
"id": 4, "id": 4,
"category": "statuses",
"displayName": "Service Status",
"children": [
{
"id": "core-service",
"displayName": "Core Services"
},
{
"id": "metadata-service",
"displayName": "Metadata Services"
}
]
},
{
"id": 5,
"category": "system", "category": "system",
"displayName": "System", "displayName": "System",
"children": [ "children": [
@@ -110,7 +67,7 @@
] ]
}, },
{ {
"id": 6, "id": 5,
"category": "acknowledgments", "category": "acknowledgments",
"displayName": "Acknowledgments", "displayName": "Acknowledgments",
"children": [ "children": [

View File

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

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