Compare commits

..

11 Commits

132 changed files with 13162 additions and 13601 deletions

View File

@@ -1,14 +1,14 @@
module.exports = { module.exports = {
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"], extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
sourceType: "module", sourceType: "module",
ecmaVersion: 2020, ecmaVersion: 2020,
ecmaFeatures: { ecmaFeatures: {
jsx: true // Allows for the parsing of JSX jsx: true // Allows for the parsing of JSX
} }
}, },
plugins: ["@typescript-eslint", "css-modules"], plugins: ["@typescript-eslint", "css-modules"],
settings: { settings: {
"import/resolver": { "import/resolver": {
@@ -18,9 +18,9 @@ module.exports = {
}, },
react: { react: {
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
} }
}, },
// Fine tune rules // Fine tune rules
rules: { rules: {
"@typescript-eslint/no-var-requires": 0 "@typescript-eslint/no-var-requires": 0

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/

19
.storybook/main.js Normal file
View File

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

View File

@@ -1,19 +0,0 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
};
export default config;

View File

@@ -1,3 +0,0 @@
<script>
window.global = window;
</script>

9
.storybook/preview.js Normal file
View File

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

View File

@@ -1,18 +0,0 @@
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
backgrounds: {
default: "light",
},
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

BIN
ComicVine Matching.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
DC++ integration.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

BIN
Dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@@ -1,23 +1,19 @@
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
# Copy package.json and lock file first to leverage Docker cache
COPY package.json ./ COPY package.json ./
COPY yarn.lock ./ COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
RUN apk --no-cache add g++ make libpng-dev python3 git libc6-compat autoconf automake bash libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
# Install build dependencies necessary for native modules
RUN apk --no-cache add g++ make libpng-dev git python3 autoconf automake libjpeg-turbo-dev mesa-dev mesa libxi build-base gcc libtool nasm
# Install node modules
RUN yarn install --ignore-engines
# Explicitly install sass
RUN yarn add -D sass
# Copy the rest of the application
COPY . . COPY . .
EXPOSE 3050
EXPOSE 5173 ENTRYPOINT [ "npm", "start" ]
# Use yarn start if you want to stick with yarn, or change to npm start if you prefer npm
ENTRYPOINT [ "yarn", "start" ]

BIN
Library.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -6,25 +6,14 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots ### Screenshots
#### Dashboard ![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/Dashboard.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg) ![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/Library.png)
#### Issue View ![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/DC%2B%2B%20integration.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg) ![](https://raw.githubusercontent.com/rishighan/threetwo/rishighan-screenshots-dec-2022/ComicVine%20Matching.png)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel ### 🦄 Early Development Support Channel
@@ -39,8 +28,7 @@ ThreeTwo! currently is set up as:
1. The UI, this repo. 1. The UI, this repo.
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service) 2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service) 3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service) 4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
## Docker Instructions ## Docker Instructions
@@ -52,21 +40,24 @@ 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
### Docker ### Docker
1. `docker-compose up` is taking a long time 1. `docker-compose up` is taking a long time
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading. This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
2. What folder do my comics go in? 2. What folder do my comics go in?
Your comics go in the `comics` directory at the root of this project. Your comics go in the `comics` directory at the root of this project.
## Contribution Guidelines ## Contribution Guidelines
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md) See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)

View File

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

View File

@@ -1,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,133 +1,133 @@
{ {
"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",
"build-storybook": "storybook build"
}, },
"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",
"@floating-ui/react": "^0.26.12", "@dnd-kit/sortable": "^5.0.0",
"@floating-ui/react-dom": "^2.0.8", "@dnd-kit/utilities": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.1.1",
"@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.5.11",
"@tanstack/react-table": "^8.9.3",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/react-router-dom": "^5.3.3", "@types/react": "^17.0.3",
"@vitejs/plugin-react": "^4.2.1", "@types/react-dom": "^17.0.2",
"airdcpp-apisocket": "^2.5.0-beta.2", "@types/react-redux": "^7.1.16",
"axios": "^1.7.4", "@types/react-router-dom": "^5.1.7",
"axios-cache-interceptor": "^1.0.1", "@types/socket.io": "^3.0.2",
"@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"airdcpp-apisocket": "2.4.5-beta.1",
"axios": "^0.27.2",
"axios-rate-limit": "^1.3.0", "axios-rate-limit": "^1.3.0",
"babel-plugin-styled-components": "^2.1.4", "axios-simple-cache-adapter": "^1.1.0",
"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.1.0",
"express": "^4.20.0", "express": "^4.17.1",
"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",
"html-to-text": "^8.1.0", "html-to-text": "^8.1.0",
"i18next": "^23.11.1",
"i18next-browser-languagedetector": "^7.2.1",
"i18next-http-backend": "^2.5.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": "^19.0.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": "^19.0.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-i18next": "^14.1.0",
"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-router": "^7.1.5", "react-redux": "^7.2.6",
"react-select": "^5.8.0", "react-router": "^6.2.2",
"react-router-dom": "^6.2.2",
"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",
"react-toastify": "^10.0.5", "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.5",
"threetwo-ui-typings": "^1.0.14", "threetwo-ui-typings": "^1.0.14",
"vite": "^5.4.12",
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34", "websocket": "^1.0.34",
"zustand": "^4.4.6" "ws": "^7.5.3",
"xml2js": "^0.4.23",
"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/react-vite": "^7.4.1",
"@storybook/testing-library": "^0.2.0",
"@tanstack/eslint-plugin-query": "^5.0.5",
"@tanstack/react-query-devtools": "^5.1.0",
"@tsconfig/node14": "^1.0.0", "@tsconfig/node14": "^1.0.0",
"@types/ellipsize": "^0.1.1",
"@types/express": "^4.17.8", "@types/express": "^4.17.8",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@types/node": "^14.14.34", "@types/node": "^14.14.34",
"@types/react": "^19.0.0", "@types/react": "^17.0.3",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.25", "@types/react-redux": "^7.1.16",
"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", "comlink": "^4.3.0",
"concurrently": "^4.0.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", "express": "^4.17.1",
"express": "^4.20.0",
"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.77.0", "sass": "^1.58.1",
"storybook": "^7.3.2", "tslint": "^6.1.3",
"tailwindcss": "^3.4.1", "typescript": "^4.2.3",
"tui-jsdoc-template": "^1.2.2", "vite": "^4.1.1"
"typescript": "^5.1.6"
},
"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: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

View File

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

View File

@@ -1,6 +1,10 @@
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 {
AxiosCacheRequestConfig,
createCacheAdapter,
} from "axios-simple-cache-adapter";
import qs from "qs";
import { import {
CV_SEARCH_SUCCESS, CV_SEARCH_SUCCESS,
CV_API_CALL_IN_PROGRESS, CV_API_CALL_IN_PROGRESS,
@@ -22,21 +26,25 @@ import {
LIBRARY_SERVICE_BASE_URI, LIBRARY_SERVICE_BASE_URI,
} from "../constants/endpoints"; } from "../constants/endpoints";
const axiosCacheAdapter = createCacheAdapter();
const http = rateLimiter(axios.create(), { const http = rateLimiter(axios.create(), {
maxRequests: 1, maxRequests: 1,
perMilliseconds: 1000, perMilliseconds: 1000,
maxRPS: 1, maxRPS: 1,
}); });
const cachedAxios = setupCache(axios);
export const getWeeklyPullList = (options) => async (dispatch) => { export const getWeeklyPullList = (options) => async (dispatch) => {
try { try {
dispatch({ dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS, type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
}); });
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get", method: "get",
params: options, params: options,
}).then((response) => { axiosCacheAdapter,
cache: 1000, // value in MS
} as AxiosCacheRequestConfig).then((response) => {
dispatch({ dispatch({
type: CV_WEEKLY_PULLLIST_FETCHED, type: CV_WEEKLY_PULLLIST_FETCHED,
data: response.data.result, data: response.data.result,
@@ -62,6 +70,9 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
"Content-Type": "application/json", "Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
}, },
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: "repeat" });
},
}); });
switch (options.callURIAction) { switch (options.callURIAction) {
@@ -126,7 +137,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
@@ -334,7 +301,7 @@ export const searchIssue = (query, options) => async (dispatch) => {
case "wantedComicsPage": case "wantedComicsPage":
dispatch({ dispatch({
type: WANTED_COMICS_FETCHED, type: WANTED_COMICS_FETCHED,
data: response.data.hits, data: response.data.body,
}); });
break; break;
case "globalSearchBar": case "globalSearchBar":
@@ -347,13 +314,13 @@ export const searchIssue = (query, options) => async (dispatch) => {
case "libraryPage": case "libraryPage":
dispatch({ dispatch({
type: SS_SEARCH_RESULTS_FETCHED, type: SS_SEARCH_RESULTS_FETCHED,
data: response.data.hits, data: response.data.body,
}); });
break; break;
case "volumesPage": case "volumesPage":
dispatch({ dispatch({
type: VOLUMES_FETCHED, type: VOLUMES_FETCHED,
data: response.data.hits, data: response.data.body,
}); });
break; break;
@@ -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,16 +1,139 @@
import React, { ReactElement } from "react"; import React, { ReactElement, useContext, useEffect } from "react";
import { Outlet } from "react-router"; import Dashboard from "./Dashboard/Dashboard";
import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
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 />
<ToastContainer stacked hideProgressBar /> <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,70 @@
import * as React from "react";
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../shared/utils/formatting.utils";
import { isUndefined, isEmpty, isNil } from "lodash";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
import ellipsize from "ellipsize";
interface IProps {
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
mongoObjId?: number;
hasTitle: boolean;
title?: string;
isHorizontal: boolean;
}
interface IState {}
class Card extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
}
public drawCoverCard = (
metadata: IExtractedComicBookCoverFile,
): JSX.Element => {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div>
<div className="card generic-card">
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
<div className="card-image">
<figure className="image">
<img src={filePath} alt="Placeholder image" />
</figure>
</div>
{this.props.hasTitle && (
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
)}
</div>
</div>
</div>
);
};
public render() {
return (
<>
{!isUndefined(this.props.comicBookCoversMetadata) &&
!isEmpty(this.props.comicBookCoversMetadata) &&
this.drawCoverCard(this.props.comicBookCoversMetadata)}
</>
);
}
}
export default Card;

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 { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; useCallback,
useContext,
ReactElement,
useEffect,
useState,
} from "react";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState, SearchInstance } from "threetwo-ui-typings"; import { 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";
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
interface IAcquisitionPanelProps { interface IAcquisitionPanelProps {
query: any; query: any;
@@ -21,461 +27,306 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = ( export const AcquisitionPanel = (
props: IAcquisitionPanelProps, props: IAcquisitionPanelProps,
): ReactElement => { ): ReactElement => {
const { socketIOInstance } = useStore(
useShallow((state) => ({
socketIOInstance: state.socketIOInstance,
})),
);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
interface SearchResult {
id: string;
// Add other properties as needed
slots: any;
type: any;
users: any;
name: string;
dupe: Boolean;
size: number;
}
const handleSearch = (searchQuery) => {
// Use the already connected socket instance to emit events
socketIOInstance.emit("initiateSearch", searchQuery);
};
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 axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
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<
SearchResult[]
>([]);
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++" to perform a search
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?.data, (item) => item.value), },
priority: 5, hub_urls: map(
}; airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
setDcppQuery(dcppSearchQuery); (item) => item.value,
}, []); ),
priority: 5,
/** };
* Method to perform a search via an AirDC++ websocket setDcppQuery(dcppSearchQuery);
* @param {SearchData} data - a SearchData query
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
*/
const search = async (searchData: any) => {
setAirDCPPSearchResults([]);
socketIOInstance.emit("call", "socket.search", {
query: searchData,
config: {
protocol: `ws`,
// hostname: `192.168.1.119:5600`,
hostname: `127.0.0.1:5600`,
username: `user`,
password: `pass`,
},
});
};
socketIOInstance.on("searchResultAdded", ({ result }: any) => {
setAirDCPPSearchResults((previousState) => {
const exists = previousState.some((item) => result.id === item.id);
if (!exists) {
return [...previousState, result];
}
return previousState;
});
});
socketIOInstance.on("searchResultUpdated", ({ result }: any) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.id === result.id,
);
const updatedState = [...airDCPPSearchResults];
if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) {
updatedState[bundleToUpdateIndex] = result;
} }
setAirDCPPSearchResults((state) => [...state, ...updatedState]); }, [airDCPPConfiguration]);
});
socketIOInstance.on("searchInitiated", (data) => { const getDCPPSearchResults = useCallback(
setAirDCPPSearchInstance(data.instance); async (searchQuery) => {
}); const manualQuery = {
socketIOInstance.on("searchesSent", (data) => { query: {
setAirDCPPSearchInfo(data.searchInfo); pattern: `${searchQuery.issueName}`,
}); extensions: ["cbz", "cbr", "cb7"],
},
/** hub_urls: map(
* Method to download a bundle associated with a search result from AirDC++ airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
* @param {Number} searchInstanceId - description (item) => item.value,
* @param {String} resultId - description ),
* @param {String} comicObjectId - description priority: 5,
* @param {String} name - description };
* @param {Number} size - description dispatch(
* @param {any} type - description search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
* @param {any} config - description username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
* @returns {void} - description password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
*/ }),
const download = async ( );
searchInstanceId: Number, },
resultId: String, [dispatch, airDCPPConfiguration],
comicObjectId: String, );
name: String,
size: Number,
type: any,
config: any,
): Promise<void> => {
socketIOInstance.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => console.log(data),
);
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: [hubs?.data[0].hub_url],
priority: 5,
};
search(manualQuery);
};
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
dispatch(
downloadAirDCPPItem(
searchInstanceId,
resultId,
props.comicObjectId,
name,
size,
type,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
// this is to update the download count badge on the downloads tab
dispatch(
getBundlesForComic(
props.comicObjectId,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
},
[airDCPPConfiguration],
);
return ( return (
<> <>
<div className="mt-5 mb-3"> <div className="comic-detail columns">
{!isEmpty(hubs?.data) ? ( {!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>
)} )}
/> />
) : ( ) : (
<article <div className="column is-three-fifths">
role="alert" <article className="message is-info">
className="mt-4 rounded-lg text-sm 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 is-size-6 is-family-secondary">
> AirDC++ is not configured. Please configure it in{" "}
No AirDC++ hub configured. Please configure it in{" "} <code>Settings</code>.
<code>Settings &gt; AirDC++ &gt; Hubs</code>. </div>
</article> </article>
)}
</div>
{/* configured hub */}
{!isEmpty(hubs?.data) && (
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
<span className="pr-1 pt-1">
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
</span>
{hubs && hubs?.data[0].hub_url}
</span>
)}
{/* AirDC++ search instance details */}
{!isNil(airDCPPSearchInstance) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs?.data.map((value, idx: string) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div> </div>
)} )}
</div>
{/* AirDC++ search instance details */}
{!isNil(searchInfo) && !isNil(searchInstance) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
({ value }) => (
<span className="tag is-warning" key={value}>
{value}
</span>
),
)}
</div>
</dt>
<dt>
Query:{" "}
<span className="has-text-weight-semibold">
{searchInfo.query.pattern}
</span>
</dt>
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<dd>File type: {searchInfo.query.file_type}</dd>
</dl>
</div>
</div>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */} {/* AirDC++ results */}
<div className=""> <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( {map(airDCPPSearchResults, ({ result }, idx) => {
airDCPPSearchResults, return (
({ dupe, type, name, id, slots, users, size }, idx) => { <tr
return ( key={idx}
<tr className={
key={idx} !isNil(result.dupe) ? "dupe-search-result" : ""
className={ }
!isNil(dupe) >
? "bg-gray-100 dark:bg-gray-700" <td>
: "w-fit text-sm" <p className="mb-2">
} {result.type.id === "directory" ? (
> <i className="fas fa-folder"></i>
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300"> ) : null}{" "}
<p className="mb-2"> {ellipsize(result.name, 70)}
{type.id === "directory" ? ( </p>
<i className="fas fa-folder"></i>
) : null}
{ellipsize(name, 70)}
</p>
<dl> <dl>
<dd> <dd>
<div className="inline-flex flex-row gap-2"> <div className="tags">
{!isNil(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"> ) : null}
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i> <span className="tag is-light is-info">
</span> {result.users.user.nicks}
</span>
<span className="text-md text-slate-500 dark:text-slate-900"> {result.users.user.flags.map((flag, idx) => (
Dupe <span className="tag is-light" key={idx}>
</span> {flag}
</span>
) : null}
{/* 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">
{users.user.nicks}
</span>
</span> </span>
{/* Flags */} ))}
{users.user.flags.map((flag, idx) => ( </div>
<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"> </dd>
<span className="pr-1 pt-1"> </dl>
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i> </td>
</span> <td>
<span className="tag is-light is-info">
<span className="text-md text-slate-500 dark:text-slate-900"> {result.type.id === "directory"
{flag} ? "directory"
</span> : result.type.str}
</span> </span>
))} </td>
</div> <td>
</dd> <div className="tags has-addons">
</dl> <span className="tag is-success">
</td> {result.slots.free} free
<td>
{/* 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">
{type.str}
</span>
</span> </span>
</td> <span className="tag is-light">
<td className="px-2"> {result.slots.total}
{/* Slots */}
<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--settings-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{slots.total} slots; {slots.free} free
</span>
</span> </span>
</td> </div>
<td className="px-2"> </td>
<button <td>
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" <a
onClick={() => onClick={() =>
download( downloadDCPPResult(
airDCPPSearchInstance.id, searchInstance.id,
id, result.id,
comicObjectId, result.name,
name, result.size,
size, result.type,
type, )
{ }
protocol: `ws`, >
hostname: `192.168.1.119:5600`, <i className="fas fa-file-download"></i>
username: `admin`, </a>
password: `password`, </td>
}, </tr>
) );
} })}
>
<span className="text-xs">Download</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span>
</button>
</td>
</tr>
);
},
)}
</tbody> </tbody>
</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>
@@ -485,4 +336,4 @@ export const AcquisitionPanel = (
); );
}; };
export default AcquisitionPanel; export default AcquisitionPanel;

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import React, { ReactElement, useCallback, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import PropTypes from "prop-types";
import { fetchMetronResource } from "../../../actions/metron.actions"; import { fetchMetronResource } from "../../../actions/metron.actions";
import Creatable from "react-select/creatable"; import Creatable from "react-select/creatable";
import { withAsyncPaginate } from "react-select-async-paginate"; import { withAsyncPaginate } from "react-select-async-paginate";
const CreatableAsyncPaginate = withAsyncPaginate(Creatable); const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
interface AsyncSelectPaginateProps { export const AsyncSelectPaginate = (props): ReactElement => {
metronResource: string;
placeholder?: string;
value?: object;
onChange?(...args: unknown[]): unknown;
}
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
const [value, setValue] = useState(null); const [value, setValue] = useState(null);
const [isAddingInProgress, setIsAddingInProgress] = useState(false); const [isAddingInProgress, setIsAddingInProgress] = useState(false);
@@ -44,4 +38,11 @@ export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactEleme
); );
}; };
AsyncSelectPaginate.propTypes = {
metronResource: PropTypes.string.isRequired,
placeholder: PropTypes.string,
value: PropTypes.object,
onChange: PropTypes.func,
};
export default AsyncSelectPaginate; export default AsyncSelectPaginate;

View File

@@ -1,10 +1,11 @@
import React, { useState, ReactElement, useCallback } from "react"; import React, { useState, ReactElement, useCallback } from "react";
import { useParams } from "react-router"; import { useDispatch, useSelector } from "react-redux";
import Card from "../shared/Carda"; import { useParams } from "react-router-dom";
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,37 +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"
), ),
name: "Torrent Search", content: !isNil(data.data) && !isEmpty(data.data) && (
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />, <DownloadsPanel
shouldShow: true, data={data.data.acquisition.directconnect}
}, comicObjectId={comicObjectId}
{ key={5}
id: 6, />
name: "Downloads",
icon: (
<>
{acquisition?.directconnect?.downloads?.length +
acquisition?.torrent.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,
}, },
]; ];
@@ -387,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"}
@@ -462,7 +309,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
> >
{slidingPanelContentId !== "" && {slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()} contentForSlidingPanel[slidingPanelContentId].content()}
</StyledSlidingPanel> </SlidingPane>
</> </>
)} )}
</div> </div>
@@ -470,4 +317,4 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
); );
}; };
export default ComicDetail; export default ComicDetail;

View File

@@ -1,35 +1,22 @@
import React, { ReactElement } from "react"; import { isEmpty, isNil, isUndefined } from "lodash";
import { useParams } from "react-router"; import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail"; 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

@@ -1,118 +1,119 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
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 => {
interface ComicVineDetailsProps {
updatedAt?: string;
data?: {
name?: string;
number?: string;
resource_type?: string;
id?: number;
};
}
export const ComicVineDetails = (props: ComicVineDetailsProps): 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>
); );
}; };
export default ComicVineDetails; export default ComicVineDetails;
ComicVineDetails.propTypes = {
updatedAt: PropTypes.string,
data: PropTypes.shape({
name: PropTypes.string,
number: PropTypes.string,
resource_type: PropTypes.string,
id: PropTypes.number,
}),
};

View File

@@ -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,140 +1,108 @@
import React, { useEffect, useContext, ReactElement, useState } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles";
import { TorrentDownloads } from "./TorrentDownloads";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { import {
LIBRARY_SERVICE_BASE_URI, getBundlesForComic,
QBITTORRENT_SERVICE_BASE_URI, } from "../../actions/airdcpp.actions";
TORRENT_JOB_SERVICE_BASE_URI, import { useDispatch, useSelector } from "react-redux";
SOCKET_BASE_URI, import { RootState } from "threetwo-ui-typings";
} from "../../constants/endpoints"; import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store"; import prettyBytes from "pretty-bytes";
import { useShallow } from "zustand/react/shallow"; import dayjs from "dayjs";
import { useParams } from "react-router"; import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
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 [infoHashes, setInfoHashes] = useState<string[]>([]); return state.airdcpp.bundles;
const [torrentDetails, setTorrentDetails] = useState([]);
const [activeTab, setActiveTab] = useState("directconnect");
const { socketIOInstance } = useStore(
useShallow((state: any) => ({
socketIOInstance: state.socketIOInstance,
})),
);
// React to torrent progress data sent over websockets
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
const torrents = data.torrents
.flatMap(({ _id, details }) => {
if (_id === comicObjectId) {
return details;
}
})
.filter((item) => item !== undefined);
setTorrentDetails(torrents);
}); });
/** // AirDCPP Socket initialization
* Query to fetch AirDC++ download bundles for a given comic resource Id const userSettings = useSelector((state: RootState) => state.settings.data);
* @param {string} {comicObjectId} - A mongo id that identifies a comic document const airDCPPConfiguration = useContext(AirDCPPSocketContext);
*/
const { data: bundles } = useQuery({
queryKey: ["bundles"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
method: "POST",
data: {
comicObjectId,
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
},
}),
enabled: activeTab !== "" && activeTab === "directconnect",
});
// Call the scheduled job for fetching torrent data const {
// triggered by the active tab been set to "torrents" airDCPPState: { socket, settings },
const { data: torrentData } = useQuery({ } = airDCPPConfiguration;
queryFn: () =>
axios({
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
method: "GET",
params: {
trigger: activeTab,
},
}),
queryKey: [activeTab],
enabled: activeTab !== "" && activeTab === "torrents",
});
console.log(bundles);
return (
<div className="columns is-multiline">
<div>
<div className="sm:hidden">
<label htmlFor="Download Type" className="sr-only">
Download Type
</label>
<select id="Tab" className="w-full rounded-md border-gray-200"> const dispatch = useDispatch();
<option>DC++ Downloads</option> // Fetch the downloaded files and currently-downloading file(s) from AirDC++
<option>Torrents</option> useEffect(() => {
</select> try {
</div> if (!isEmpty(userSettings)) {
dispatch(
getBundlesForComic(props.comicObjectId, socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
}
} catch (error) {
throw new Error(error);
}
}, [dispatch, airDCPPConfiguration]);
<div className="hidden sm:block"> const Bundles = (props) => {
<nav className="flex gap-6" aria-label="Tabs"> return !isEmpty(props.data) ? (
<a <div className="column is-full">
href="#" <table className="table is-striped">
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${ <thead>
activeTab === "directconnect" <tr>
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800" <th>Filename</th>
: "dark:text-slate-400 text-slate-800" <th>Size</th>
}`} <th>Download Time</th>
aria-current="page" <th>Bundle ID</th>
onClick={() => setActiveTab("directconnect")} </tr>
> </thead>
DC++ Downloads <tbody>
</a> {map(props.data, (bundle) => (
<tr key={bundle.id}>
<a <td>
href="#" <h5>{ellipsize(bundle.name, 58)}</h5>
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${ <span className="is-size-7">{bundle.target}</span>
activeTab === "torrents" </td>
? "bg-slate-200 text-slate-800" <td>{prettyBytes(bundle.size)}</td>
: "dark:text-slate-400 text-slate-800" <td>
}`} {dayjs
onClick={() => setActiveTab("torrents")} .unix(bundle.time_finished)
> .format("h:mm on ddd, D MMM, YYYY")}
Torrents </td>
</a> <td>
</nav> <span className="tag is-warning">{bundle.id}</span>
</div> </td>
</tr>
))}
</tbody>
</table>
</div> </div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
{activeTab === "torrents" ? ( return !isNil(props.data) ? (
<TorrentDownloads data={torrentDetails} /> <>
) : null} <div className="columns is-multiline">
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? ( {!isEmpty(socket) ? (
<AirDCPPBundles data={bundles.data} /> <Bundles data={bundles} />
) : ( ) : (
"nutin" <div className="column is-three-fifths">
)} <article className="message is-info">
</div> <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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,83 +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: {
wanted: { $exists: false },
},
comicStatus: "recent",
}, },
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}), }),
queryKey: ["recentComics"], );
}); dispatch(
// Wanted Comics 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: {
wanted: { $exists: true, $ne: null },
},
}, },
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 { Link } from "react-router"; import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import axios from "axios"; import "slick-carousel/slick/slick-theme.css";
import rateLimiter from "axios-rate-limit"; import { Link } from "react-router-dom";
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-1-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://www.tfaw.com/comics/new-releases.html"> <div className="field is-grouped">
Things From Another World
</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.coverImageUrl} 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.publicationDate} }}
</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,40 +1,53 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import Card from "../shared/Carda"; import Card from "../Carda";
import { Link } from "react-router"; 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 => {
console.log(comics); 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: {
wanted: { source } = {}, source: { name },
},
}, },
idx, idx,
) => { ) => {
@@ -44,88 +57,90 @@ export const RecentlyImported = (
comicInfo, comicInfo,
locg, locg,
}); });
const { issue, coverURL, icon } = determineExternalMetadata( const { issue, coverURL, icon } = determineExternalMetadata(name, {
source, comicvine,
{ comicInfo,
comicvine, locg,
comicInfo, });
locg, const isComicBookMetadataAvailable =
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return ( return (
<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/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/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

@@ -1,11 +1,16 @@
import { map, unionBy } from "lodash"; 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"; 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"; 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,26 +14,47 @@ 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,
({ ({
_id, _id,
rawFileDetails, rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg }, sourcedMetadata: { comicvine, comicInfo, locg },
wanted,
}) => { }) => {
const isComicBookMetadataAvailable = !isUndefined(comicvine); const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = { const consolidatedComicMetadata = {
rawFileDetails, rawFileDetails,
comicvine, comicvine,
@@ -41,82 +62,53 @@ export const WantedComicsList = ({
locg, locg,
}; };
const { const { issueName, url } = determineCoverFile(
issueName, consolidatedComicMetadata,
url, );
publisher = null,
} = determineCoverFile(consolidatedComicMetadata);
const titleElement = ( const titleElement = (
<Link to={"/comic/details/" + _id}> <Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)} {ellipsize(issueName, 20)}
<p>{publisher}</p>
</Link> </Link>
); );
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">
<div className="flex flex-row gap-2">
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(detectIssueTypes(comicvine.description)) ? (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(comicvine.description)
.displayName
}
</span>
</span>
</div>
) : null}
{/* issues marked as wanted, part of this volume */}
{wanted?.markEntireVolumeWanted ? (
<div className="text-sm">sagla volume pahije</div>
) : (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{wanted.issues.length}
</span>
</span>
</div>
)}
</div>
{/* comicVine metadata presence */} {/* comicVine metadata presence */}
{isComicBookMetadataAvailable && ( {isComicBookMetadataAvailable && (
<img <span className="icon custom-icon">
src="/src/client/assets/img/cvlogo.svg" <img src="/src/client/assets/img/cvlogo.svg" />
alt={"ComicVine metadata detected."} </span>
className="w-7 h-7"
/>
)} )}
{!isEmpty(locg) && ( {!isEmpty(locg) && (
<img <span className="icon custom-icon">
src="/src/client/assets/img/locglogo.svg" <img src="/src/client/img/locglogo.svg" />
className="w-7 h-7" </span>
/>
)} )}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</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,6 +1,9 @@
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 { searchIssue } from "../../actions/fileops.actions";
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 +12,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,86 +38,62 @@ 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]);
return !isNil(bundles) ? ( }, [issueBundles])
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>
<div className="columns"> <div className="columns">
<div className="column is-half"> <div className="column is-half">
{bundles.map((bundle, idx) => { {bundles.map(bundle => {
console.log(bundle); console.log(bundle);
return ( return <>
<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> <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> {bundle.acquisition.directconnect.downloads.map((bundle) => {
<tbody> return(<tr>
{bundle.acquisition.directconnect.downloads.map( <td>{bundle.name}</td>
(bundle, idx) => { <td>{bundle.size}</td>
return ( <td>{bundle.type.str}</td>
<tr key={idx}> <td><span className="tag is-warning">{bundle.bundleId}</span></td>
<td>{bundle.name}</td> </tr>)
<td>{bundle.size}</td> })}
<td>{bundle.type.str}</td>
<td>
<span className="tag is-warning">
{bundle.bundleId}
</span>
</td>
</tr>
);
},
)}
</tbody>
</table> </table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */} {/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</div> </>
);
})} })}
</div> </div>
</div> </div>
</section> </section>
</div> </div> : <div>asd</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";
@@ -61,10 +61,9 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
margin: "60px 0 0 350px", margin: "60px 0 0 350px",
}} }}
> >
{map(searchResults, (result, idx) => ( {map(searchResults, (result) => (
<MetadataPanel <MetadataPanel
data={result} data={result}
key={idx}
imageStyle={{ maxWidth: 70 }} imageStyle={{ maxWidth: 70 }}
titleStyle={{ fontSize: "0.8rem" }} titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }} tagsStyle={{ fontSize: "0.7rem" }}

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"; 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,33 @@ 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) => {
query: {}, console.log(state);
pagination: { return state.fileOps.librarySearchError;
size: 25,
from: offset,
},
type: "all",
trigger: "libraryPage",
}); });
const queryClient = useQueryClient(); const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
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 +50,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 +107,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 +122,28 @@ export const Library = (): ReactElement => {
header: "Additional Metadata", header: "Additional Metadata",
columns: [ columns: [
{ {
header: "Date of Import", header: "Publisher",
accessorKey: "_source.createdAt", accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (info) => { cell: (info) => {
return !isNil(info.getValue()) ? ( return (
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900"> !isNil(info.getValue()) && (
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p> <h6 className="is-size-7 has-text-weight-bold">
{format(parseISO(info.getValue()), "h aaaa")} {info.getValue().publisher.name}
</div> </h6>
) : null; )
);
}, },
}, },
{ {
header: "Downloads", header: "Something",
accessorKey: "_source.acquisition", accessorKey: "_source.acquisition.source.wanted",
cell: (info) => ( cell: (info) => {
<div className="flex flex-col gap-2 ml-3 my-3"> !isUndefined(info.getValue()) ? (
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <WantedStatus value={info.getValue().toString()} />
<span className="pr-1 pt-1"> ) : (
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i> "Nothing"
</span> );
<span className="text-md text-slate-900 dark:text-slate-900"> },
DC++: {info.getValue().directconnect.downloads.length}
</span>
</span>
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-900 dark:text-slate-900">
Torrent: {info.getValue().torrent.length}
</span>
</span>
</div>
),
}, },
], ],
}, },
@@ -198,21 +159,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 +184,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.hits.total.value}
columns={columns} columns={columns}
sourceData={searchResults?.hits.hits} sourceData={searchResults?.hits?.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,9 +12,9 @@ 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"; import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints"; import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
interface ILibraryGridProps {} interface ILibraryGridProps {}
@@ -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"; 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

@@ -0,0 +1,261 @@
import React, { useContext } from "react";
import { SearchBar } from "./GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { useSelector } from "react-redux";
import { isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns";
const Navbar: React.FunctionComponent = (props) => {
const downloadProgressTick = useSelector(
(state: RootState) => state.airdcpp.downloadProgressData,
);
const airDCPPSocketConnectionStatus = useSelector(
(state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
);
const airDCPPSessionInfo = useSelector(
(state: RootState) => state.airdcpp.airDCPPSessionInfo,
);
const socketDisconnectionReason = useSelector(
(state: RootState) => state.airdcpp.socketDisconnectionReason,
);
return (
<nav className="navbar is-fixed-top">
<div className="navbar-brand">
<Link to="/" className="navbar-item">
<img
src="/src/client/assets/img/threetwo.svg"
alt="ThreeTwo! A comic book curator"
width="112"
height="28"
/>
</Link>
<a className="navbar-item is-hidden-desktop">
<span className="icon">
<i className="fas fa-github"></i>
</span>
</a>
<a className="navbar-item is-hidden-desktop">
<span className="icon">
<i className="fas fa-twitter"></i>
</span>
</a>
<div className="navbar-burger burger" data-target="navMenubd-example">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div id="navMenubd-example" className="navbar-menu">
<div className="navbar-start">
<Link to="/" className="navbar-item">
Dashboard
</Link>
<Link to="/import" className="navbar-item">
Import
</Link>
<Link to="/library" className="navbar-item">
Library
</Link>
<Link to="/downloads" className="navbar-item">
Downloads
</Link>
<SearchBar />
<Link to="/search" className="navbar-item">
Search ComicVine
</Link>
</div>
<div className="navbar-end">
<a className="navbar-item is-hidden-desktop-only"></a>
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i>
{downloadProgressTick && <div className="pulsating-circle"></div>}
</a>
{!isUndefined(downloadProgressTick) ? (
<div className="navbar-dropdown is-right">
<a className="navbar-item">
<DownloadProgressTick data={downloadProgressTick} />
</a> </div>
) : null}
</div>
{/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{airDCPPSocketConnectionStatus ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pt-4 pr-2 pl-2 is-right airdcpp-status">
{/* AirDC++ Session Information */}
<p>
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(airDCPPSessionInfo.user.last_login),
"dd MMMM, yyyy",
)}
</span>
</p>
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInfo.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.session_id}
</span>
</p>
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
</div>
</>
) : (
<>
<a className="navbar-link is-arrowless has-text-danger">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right">
<pre>
{JSON.stringify(socketDisconnectionReason, null, 2)}
</pre>
</div>
</>
)}
</div>
<div className="navbar-item has-dropdown is-hoverable is-mega">
<div className="navbar-link flex">Blog</div>
<div id="blogDropdown" className="navbar-dropdown">
<div className="container is-fluid">
<div className="columns">
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a
className="navbar-item "
href="http://bulma.io/documentation/columns/basics/"
>
Columns
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
<div className="navbar-content">
<p>
<small className="has-text-info">03 Aug 2017</small>
</p>
<p>New feature: list of tags</p>
</div>
</a>
</div>
<div className="column">
<h1 className="title is-6 is-mega-menu-title">
Sub Menu Title
</h1>
<a
className="navbar-item "
href="/documentation/overview/start/"
>
Overview
</a>
</div>
</div>
</div>
<hr className="navbar-divider" />
<div className="navbar-item">
<div className="navbar-content">
<div className="level is-mobile">
<div className="level-left">
<div className="level-item">
<strong>Stay up to date!</strong>
</div>
</div>
<div className="level-right">
<div className="level-item">
<a
className="button bd-is-rss is-small"
href="http://bulma.io/atom.xml"
>
<span className="icon is-small">
<i className="fa fa-rss"></i>
</span>
<span>Subscribe</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="navbar-item">
<div className="field is-grouped">
<p className="control">
<Link to="/settings" className="navbar-item">
Settings
</Link>
</p>
</div>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

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,468 +0,0 @@
import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util";
import PopoverButton from "../shared/PopoverButton";
import dayjs from "dayjs";
import { useMutation } 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 [comicVineMetadata, setComicVineMetadata] = useState({});
const [selectedResource, setSelectedResource] = useState("volume");
const { t } = useTranslation();
const handleResourceChange = (value) => {
setSelectedResource(value);
};
const {
mutate,
data: comicVineSearchResults,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (data: { search: string; resource: string }) => {
const { search, resource } = data;
return await axios({
url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: resource,
},
});
},
});
// add to library
const { data: additionResult, mutate: addToWantedList } = useMutation({
mutationFn: async ({
source,
comicObject,
markEntireVolumeWanted,
resourceType,
}) => {
let volumeInformation = {};
let issues = [];
switch (resourceType) {
case "issue":
const { id, api_detail_url, image, cover_date, issue_number } =
comicObject;
// Add issue metadata
issues.push({
id,
url: api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
console.log(issues);
// Get volume metadata from CV
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
method: "POST",
data: {
volumeURI: comicObject.volume.api_detail_url,
fieldList:
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
},
});
// set volume metadata key
volumeInformation = response.data?.results;
break;
case "volume":
const {
id: volumeId,
api_detail_url: apiUrl,
image: volumeImage,
name,
publisher,
} = comicObject;
volumeInformation = {
id: volumeId,
url: apiUrl,
image: volumeImage,
name,
publisher,
};
break;
default:
console.log("Invalid resource type.");
break;
}
// Add to wanted list
return await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
importStatus: {
isImported: false, // wanted, but not acquired yet.
tagged: false,
matchedResult: {
score: "0",
},
},
wanted: {
source,
markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
},
},
});
},
});
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const createDescriptionMarkup = (html) => {
return { __html: html };
};
const onSubmit = async (values) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
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={onSubmit}
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>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form>
)}
/>
</div>
{isPending && (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Loading results...
</div>
)}
{!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 result.resource_type === "issue" ? (
<div
key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
{result.cover_date && (
<p>
<span className="tag is-light">Cover date</span>
{dayjs(result.cover_date).format("MMM D, YYYY")}
</p>
)}
<p className="tag is-warning">{result.id}</p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`This will add ${result.volume.name} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
</div>
</div>
</div>
) : (
result.resource_type === "volume" && (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>}
</div>
<div className="flex flex-row gap-2">
{/* issue count */}
{result.count_of_issues && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{t("issueWithCount", {
count: result.count_of_issues,
})}
</span>
</span>
</div>
)}
{/* type: TPB, one-shot, graphic novel etc. */}
{!isNil(result.description) &&
!isUndefined(result.description) && (
<>
{!isEmpty(
detectIssueTypes(result.description),
) && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
</div>
)}
</>
)}
</div>
<span className="tag is-warning">{result.id}</span>
<p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`Adding this volume will add ${t(
"issueWithCount",
{
count: result.count_of_issues,
},
)} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: true,
resourceType: "volume",
})
}
/>
</div>
</div>
</div>
</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,173 +0,0 @@
import React, { ReactElement, useState } 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 axios from "axios";
import { produce } from "immer";
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient();
const {
data: settings,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
staleTime: Infinity,
});
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
let hubList: any[] = [];
if (!isNil(hubs)) {
hubList = hubs?.data.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
const mutation = 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: (data) => {
queryClient.setQueryData(["settings"], (oldData: any) =>
produce(oldData, (draft: any) => {
draft.data.directConnect.client = {
...draft.data.directConnect.client,
...data.data.directConnect.client,
};
}),
);
},
});
const validate = async (values) => {
const errors = {};
// Add any validation logic here if needed
return errors;
};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error loading settings.</div>;
}
return (
<>
{!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form
onSubmit={(values) => {
mutation.mutate(values);
}}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} className="mt-10">
<h2 className="text-xl">Configure DC++ Hubs</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"
>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against. Your
selection in the dropdown <strong>will replace</strong> the
existing selection.
</h6>
</article>
<div className="field">
<label className="block py-1 mt-3">AirDC++ Host</label>
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
<button
type="submit"
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"
>
Submit
</button>
</form>
)}
/>
) : (
<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"
>
<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"></div>
</article>
</div>
<div>
<span className="flex items-center mt-10 mb-4">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Default Hub for Searches
</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">
{settings?.data.directConnect?.client.hubs.map(
({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</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,89 +0,0 @@
import React, { useState, useEffect } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
AIRDCPP_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
} from "../../../constants/endpoints";
export const AirDCPPSettingsForm = () => {
const [airDCPPSessionInformation, setAirDCPPSessionInformation] =
useState(null);
// Fetching all settings
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({
queryKey: ["airDCPPSettings"],
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
});
// Fetch session information
const fetchSessionInfo = (host) => {
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
};
// Use effect to trigger side effects on settings fetch success
useEffect(() => {
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) {
const host = settingsData.data.directConnect.client.host;
fetchSessionInfo(host).then((response) => {
setAirDCPPSessionInformation(response.data);
});
}
}, [settingsSuccess, settingsData]);
// Handle setting update and subsequent AirDC++ initialization
const { mutate } = useMutation({
mutationFn: (values) => {
console.log(values);
return axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: values,
settingsKey: "directConnect",
});
},
onSuccess: async (response) => {
const host = response?.data?.directConnect?.client?.host;
if (host) {
const response = await fetchSessionInfo(host);
setAirDCPPSessionInformation(response.data);
// setState({ airDCPPClientConfiguration: host });
}
},
});
const deleteSettingsMutation = useMutation(() =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
return (
<>
<ConnectionForm
initialData={initFormData}
submitHandler={mutate}
formHeading={"Configure AirDC++"}
/>
{airDCPPSessionInformation && (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
)}
{settingsData?.data && (
<p className="control mt-4">
<button
className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()}
>
Delete
</button>
</p>
)}
</>
);
};
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,82 +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
// get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET",
}),
});
// 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">
@@ -48,11 +47,15 @@ export const SystemSettingsForm = (): ReactElement => {
</article> </article>
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500" className={
onClick={() => flushDb()} isSettingsCallInProgress
? "button is-danger is-loading"
: "button is-danger"
}
onClick={flushDatabase}
> >
<span className="pt-1 px-1"> <span className="icon">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i> <i className="fas fa-eraser"></i>
</span> </span>
<span>Flush DB & Temporary Folders</span> <span>Flush DB & Temporary Folders</span>
</button> </button>

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

@@ -1,25 +1,30 @@
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash"; import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
import React, { ReactElement, useState, useCallback } from "react"; import React, { useEffect, ReactElement, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { analyzeLibrary } from "../../actions/comicinfo.actions"; import {
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query"; getComicBookDetailById,
getIssuesForSeries,
analyzeLibrary,
} from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches"; import PotentialLibraryMatches from "./PotentialLibraryMatches";
import { Card } from "../shared/Carda"; import Masonry from "react-masonry-css";
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";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
const VolumeDetails = (props): ReactElement => { const VolumeDetails = (props): ReactElement => {
const breakpointColumnsObj = {
default: 6,
1100: 4,
700: 3,
500: 2,
};
// sliding panel config // sliding panel config
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [matches, setMatches] = useState([]); const [matches, setMatches] = useState([]);
const [storyArcsData, setStoryArcsData] = useState([]);
const [active, setActive] = useState(1); const [active, setActive] = useState(1);
// sliding panel init // sliding panel init
@@ -28,9 +33,7 @@ const VolumeDetails = (props): ReactElement => {
content: () => { content: () => {
const ids = map(matches, partialRight(pick, "_id")); const ids = map(matches, partialRight(pick, "_id"));
const matchIds = ids.map((id: any) => id._id); const matchIds = ids.map((id: any) => id._id);
{ return <PotentialLibraryMatches matches={matchIds} />;
/* return <PotentialLibraryMatches matches={matchIds} />; */
}
}, },
}, },
}; };
@@ -42,145 +45,68 @@ const VolumeDetails = (props): ReactElement => {
setVisible(true); setVisible(true);
}, []); }, []);
// const analyzeIssues = useCallback((issues) => { const analyzeIssues = useCallback((issues) => {
// dispatch(analyzeLibrary(issues)); dispatch(analyzeLibrary(issues));
// }, []); }, []);
//
const comicBookDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const issuesForVolume = useSelector(
(state: RootState) => state.comicInfo.issuesForVolume,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getIssuesForSeries(comicObjectId));
dispatch(getComicBookDetailById(comicObjectId));
}, []);
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { data: comicObject, isSuccess: isComicObjectFetchedSuccessfully } =
useQuery({
queryFn: async () =>
axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
queryKey: ["comicObject"],
});
// get issues for a series
const {
data: issuesForSeries,
isSuccess,
isFetching,
} = useQuery({
queryFn: async () =>
await axios({
url: `${COMICVINE_SERVICE_URI}/getIssuesForVolume`,
method: "POST",
data: {
volumeId:
comicObject?.data?.sourcedMetadata.comicvine.volumeInformation.id,
},
}),
queryKey: ["issuesForSeries", comicObject?.data],
enabled: !isUndefined(comicObject?.data),
});
// get story arcs
const useGetStoryArcs = () => {
return useMutation({
mutationFn: async (comicObject) =>
axios({
url: `${COMICVINE_SERVICE_URI}/getResource`,
method: "POST",
data: {
comicObject,
resource: "issue",
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
},
}),
onSuccess: (data) => {
setStoryArcsData(data?.data.results);
},
});
};
const {
mutate: getStoryArcs,
isIdle,
isError,
data,
error,
status,
} = useGetStoryArcs();
const IssuesInVolume = () => ( const IssuesInVolume = () => (
<> <>
{!isUndefined(issuesForSeries) ? ( {!isUndefined(issuesForVolume) ? (
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}> <div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
Analyze Library Analyze Library
</div> </div>
) : null} ) : null}
<> <Masonry
{isSuccess && breakpointCols={breakpointColumnsObj}
issuesForSeries.data.map((issue) => { className="issues-container"
return ( columnClassName="issues-column"
<> >
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
? issuesForVolume.map((issue) => {
return (
<Card <Card
key={issue.id} key={issue.id}
imageUrl={issue.image.small_url} imageUrl={issue.image.thumb_url}
orientation={"cover-only"} orientation={"vertical"}
hasDetails={false} hasDetails
/> borderColorClass={
<span className="tag is-warning mr-1"> !isEmpty(issue.matches) ? "green-border" : ""
{issue.issue_number} }
</span> backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
{!isEmpty(issue.matches) ? ( onClick={() =>
<> openPotentialLibraryMatchesPanel(issue.matches)
<span className="icon has-text-success"> }
<i className="fa-regular fa-asterisk"></i> >
</span> <span className="tag is-warning mr-1">
</> {issue.issue_number}
) : null} </span>
</> {!isEmpty(issue.matches) ? (
); <>
})} <span className="icon has-text-success">
</> <i className="fa-regular fa-asterisk"></i>
</> </span>
); </>
) : null}
const Issues = () => ( </Card>
<> );
<article })
role="alert" : "loading"}
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" </Masonry>
>
<div>
You can add a single issue or the whole volume, and it will be added
to the list of `Wanted` items.
</div>
</article>
<div className="flex flex-wrap">
{isSuccess &&
issuesForSeries?.data.map((issue) => {
return (
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
<div className="flex flex-row gap-4 mb-2">
<div className="w-fit">
<img
src={issue.image.thumb_url}
className="w-full rounded-md"
/>
</div>
<div className="w-3/4">
<p className="text-xl">{issue.name}</p>
<p className="text-sm">
{convert(issue.description, {
baseElements: {
selectors: ["p"],
},
})}
</p>
</div>
</div>
</div>
);
})}
</div>
</> </>
); );
@@ -189,44 +115,20 @@ const VolumeDetails = (props): ReactElement => {
{ {
id: 1, id: 1,
name: "Issues in Volume", name: "Issues in Volume",
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>, icon: <i className="fa-solid fa-layer-group"></i>,
content: <Issues />, content: <IssuesInVolume key={1} />,
}, },
{ {
id: 2, id: 2,
icon: ( icon: <i className="fa-regular fa-mask"></i>,
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
),
name: "Characters", name: "Characters",
content: <div key={2}>Characters</div>, content: <div key={2}>asdasd</div>,
}, },
{ {
id: 3, id: 3,
icon: ( icon: <i className="fa-solid fa-scroll"></i>,
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i> name: "Arcs",
), content: <div key={3}>asdasd</div>,
name: "Story Arcs",
content: (
<div key={3}>
<button className="" onClick={() => getStoryArcs(comicObject?.data)}>
Get story arcs
</button>
{status === "pending" && <>{status}</>}
{!isEmpty(storyArcsData) && status === "success" && (
<>
<ul>
{storyArcsData.map((storyArc) => {
return (
<li>
<span className="text-lg">{storyArc?.name}</span>
</li>
);
})}
</ul>
</>
)}
</div>
),
}, },
]; ];
@@ -234,26 +136,21 @@ const VolumeDetails = (props): ReactElement => {
const MetadataTabGroup = () => { const MetadataTabGroup = () => {
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-4" aria-label="Tabs"> {tabGroup.map(({ id, name, icon }) => (
{tabGroup.map(({ id, name, icon }) => ( <li
<a key={id}
key={id} className={id === active ? "is-active" : ""}
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${ onClick={() => setActive(id)}
active === id >
? "border-b border-cyan-50 dark:text-slate-200" <a>
: "border-b border-transparent" <span className="icon is-small">{icon}</span>
}`}
aria-current="page"
onClick={() => setActive(id)}
>
<span className="pt-1">{icon}</span>
{name} {name}
</a> </a>
))} </li>
</nav> ))}
</div> </ul>
</div> </div>
{tabGroup.map(({ id, content }) => { {tabGroup.map(({ id, content }) => {
return active === id ? content : null; return active === id ? content : null;
@@ -261,103 +158,97 @@ const VolumeDetails = (props): ReactElement => {
</> </>
); );
}; };
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
const { sourcedMetadata } = comicObject.data;
return (
<>
<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">
Volumes
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white"> if (
Browse your collection of volumes. !isUndefined(comicBookDetails.sourcedMetadata) &&
</p> !isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
</div> ) {
</div> return (
</div> <div className="container volume-details">
</header> <div className="section">
<div className="container mx-auto mt-4"> {/* Title */}
<div> <h1 className="title">
<div className="flex flex-row gap-4"> {comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
{/* Volume cover */} </h1>
<div className="columns is-multiline">
{/* Volume cover */}
<div className="column is-narrow">
<Card <Card
imageUrl={ imageUrl={
sourcedMetadata.comicvine.volumeInformation.image.small_url comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.image.small_url
} }
orientation={"cover-only"} cardContainerStyle={{ maxWidth: 275 }}
orientation={"vertical"}
hasDetails={false} hasDetails={false}
/> />
</div>
<div> <div className="column is-three-fifths">
<div className="field is-grouped"> <div className="field is-grouped mt-2">
{/* Title */} {/* Comicvine Id */}
<span className="text-2xl"> <div className="control">
{sourcedMetadata.comicvine.volumeInformation.name} <div className="tags has-addons">
</span> <span className="tag">ComicVine Id</span>
{/* Comicvine Id */} <span className="tag is-info is-light">
<div className="control"> {
<div className="tags has-addons"> comicBookDetails.sourcedMetadata.comicvine
<span className="tag">ComicVine Id</span> .volumeInformation.id
<span className="tag is-info is-light"> }
{sourcedMetadata.comicvine.volumeInformation.id} </span>
</span>
</div>
</div>
{/* Publisher */}
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning is-light">Publisher</span>
<span className="tag is-volume-related">
{
sourcedMetadata.comicvine.volumeInformation.publisher
.name
}
</span>
</div>
</div> </div>
</div> </div>
{/* Publisher */}
{/* Deck */} <div className="control">
<div> <div className="tags has-addons">
{!isEmpty( <span className="tag is-warning is-light">Publisher</span>
sourcedMetadata.comicvine.volumeInformation.description, <span className="tag is-volume-related">
) {
? ellipsize( comicBookDetails.sourcedMetadata.comicvine
convert( .volumeInformation.publisher.name
sourcedMetadata.comicvine.volumeInformation }
.description, </span>
{ </div>
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div> </div>
</div> </div>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */} {/* Deck */}
<div>
{!isEmpty(
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.description,
)
? ellipsize(
convert(
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div>
</div> </div>
<MetadataTabGroup />
</div>
<SlidingPane {/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
isOpen={visible} </div>
onRequestClose={() => setVisible(false)} <MetadataTabGroup />
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div> </div>
</>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
); );
} else { } else {
return <></>; return <></>;

View File

@@ -1,28 +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 { Link } from "react-router"; import { isUndefined } from "lodash";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Volumes = (props): ReactElement => { export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes); const volumes = useSelector((state: RootState) => state.fileOps.volumes);
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,
@@ -30,61 +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,
accessorFn: (row) => row, accessorKey: "_source",
cell: (row): any => { cell: (row) => {
const comicObject = row.getValue(); const foo = row.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
console.log("jaggu", row.getValue());
return ( return (
<div className="flex flex-row gap-3 mt-5"> <div className="columns">
<Link to={`/volume/details/${comicObject._id}`}> <div className="column">
<Card <div className="comic-detail issue-metadata">
imageUrl={ <dl>
sourcedMetadata.comicvine.volumeInformation.image.small_url <dd>
} <div className="columns mt-2">
orientation={"cover-only"} <div className="">
hasDetails={false} <Card
/> imageUrl={
</Link> foo.sourcedMetadata.comicvine.volumeInformation
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3"> .image.thumb_url
<div className="text-xl mb-1 w-fit"> }
{sourcedMetadata.comicvine.volumeInformation.name} orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div> </div>
<p>
{ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
180,
)}
</p>
</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) => {
@@ -105,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>
);
},
}, },
], ],
}, },
@@ -140,45 +157,28 @@ 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?.hits}
totalPages={volumes?.data.hits.hits.length} totalPages={volumes.hits.hits.length}
paginationHandlers={{ paginationHandlers={{
nextPage: () => {}, nextPage: () => {},
previousPage: () => {}, previousPage: () => {},
}} }}
rowClickHandler={() => {}}
columns={columnData} columns={columnData}
/> />
</div> </div>
</div> </div>
) : null} )}
{isError ? ( </div>
<div>An error was encountered while retrieving volumes</div> </section>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
); );
}; };

View File

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

View File

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

View File

@@ -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 max-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>
)}
/>
</>
);
};

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