Compare commits
79 Commits
rishighan-
...
airdcpp-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8d71f51b | |||
| 123e273ceb | |||
| 2d72842166 | |||
| c9b99e8bb4 | |||
| 173e314032 | |||
|
|
e92b86792e | ||
| 8a32dc41df | |||
| 5d430504ec | |||
| af130384f1 | |||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| b06c85d6dc | |||
| fe6100bb79 | |||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 | |||
|
|
f57bd35cd4 | ||
|
|
56c9fb03ac | ||
|
|
26c9c1e562 | ||
|
|
51e18c65d1 | ||
|
|
b82d5fd350 | ||
| 9a3ccba719 | |||
|
|
c89e4af328 | ||
| df4cecedf0 | |||
| 3dee53f33f | |||
| ef05dee600 | |||
| 2b6bce4731 | |||
| fc4c5c61e2 | |||
| ae04260f69 | |||
|
|
ad5fc0b8b3 | ||
| c03f706e9d | |||
| 4f49e538a8 | |||
|
|
68442894d0 | ||
| dba520b4c1 | |||
| ef75dad4e2 | |||
| 8bebffd95e | |||
|
|
1bd3d611e4 | ||
|
|
825782fe13 | ||
| 32f4055daa | |||
|
|
c20f24b1a2 | ||
|
|
e4fc28f698 | ||
|
|
d6c183339f | ||
|
|
0eba47e20f | ||
| 928bfd573e | |||
| b8fb179ac6 | |||
| 94692bb6d4 | |||
| 4b795aca5d | |||
| fbf6bed4fe | |||
|
|
18b18c3d81 | ||
| a939bf4c71 | |||
| 0a48ecbb2c | |||
| c5dd1abcdd | |||
| 79f9b22fad | |||
|
|
e291b4806b | ||
| ff7b7c9207 | |||
| 2d61fa1436 | |||
| b12849efda | |||
| fb2c969c00 | |||
| d437fddd1a | |||
| 8dd68e9d54 | |||
|
|
a8b52d0ac6 | ||
| 719ebe7c6e | |||
| ddef87ea29 | |||
| 71bad167ab | |||
| df1fbc7dd3 | |||
| 789e5b9518 | |||
| ce6653b5d7 | |||
| d065225d8e | |||
|
|
f854ff9cc6 | ||
| 815444a973 | |||
| 1cbf53be98 |
17
.babelrc
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/transform-runtime",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-syntax-top-level-await"
|
||||
],
|
||||
"env": {
|
||||
"production": {
|
||||
"presets": ["minify"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
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"],
|
||||
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"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true // Allows for the parsing of JSX
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
plugins: ["@typescript-eslint", "css-modules"],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
@@ -18,9 +18,9 @@ module.exports = {
|
||||
},
|
||||
react: {
|
||||
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
// Fine tune rules
|
||||
rules: {
|
||||
"@typescript-eslint/no-var-requires": 0
|
||||
|
||||
2
.github/workflows/docker-image.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
uses: elgohr/Publish-Docker-Github-Action@v5
|
||||
with:
|
||||
name: frishi/threetwo
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
|
||||
1
.gitignore
vendored
@@ -4,6 +4,7 @@ comics/
|
||||
docs/
|
||||
userdata/
|
||||
dist/
|
||||
storybook-static/*
|
||||
src/client/assets/scss/App.css
|
||||
/server/
|
||||
node_modules/
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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",
|
||||
},
|
||||
};
|
||||
19
.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
3
.storybook/preview-head.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
}
|
||||
18
.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
22
Dockerfile
@@ -1,19 +1,23 @@
|
||||
FROM node:17.3-alpine
|
||||
FROM node:18.15.0-alpine
|
||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||
|
||||
WORKDIR /threetwo
|
||||
|
||||
# Copy package.json and lock file first to leverage Docker cache
|
||||
COPY package.json ./
|
||||
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 . .
|
||||
EXPOSE 3050
|
||||
|
||||
ENTRYPOINT [ "npm", "start" ]
|
||||
EXPOSE 5173
|
||||
|
||||
# Use yarn start if you want to stick with yarn, or change to npm start if you prefer npm
|
||||
ENTRYPOINT [ "yarn", "start" ]
|
||||
|
||||
37
README.md
@@ -4,6 +4,27 @@ ThreeTwo! _aims to be_ a comic book curation app.
|
||||
|
||||
[](https://github.com/rishighan/threetwo/actions/workflows/docker-image.yml)
|
||||
|
||||
### Screenshots
|
||||
|
||||
#### Dashboard
|
||||
|
||||

|
||||
|
||||
#### Issue View
|
||||
|
||||

|
||||
|
||||
#### DC++ Search
|
||||
|
||||

|
||||
|
||||
#### Import
|
||||
|
||||

|
||||
|
||||
#### Comic Vine Matching, Metadata Scraping
|
||||
|
||||

|
||||
|
||||
### 🦄 Early Development Support Channel
|
||||
|
||||
@@ -18,7 +39,8 @@ ThreeTwo! currently is set up as:
|
||||
1. The UI, this repo.
|
||||
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
|
||||
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
|
||||
4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
||||
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
|
||||
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
||||
|
||||
## Docker Instructions
|
||||
|
||||
@@ -30,24 +52,21 @@ 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`
|
||||
2. `yarn run dev` (you can ignore the warnings)
|
||||
3. This will open `http://localhost:3050` in your default browser
|
||||
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.
|
||||
|
||||
3. This will open `http://localhost:5173` in your default browser
|
||||
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker
|
||||
|
||||
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.
|
||||
|
||||
2. What folder do my comics go in?
|
||||
|
||||
|
||||
Your comics go in the `comics` directory at the root of this project.
|
||||
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)
|
||||
|
||||
|
||||
1
funding.yml
Normal file
@@ -0,0 +1 @@
|
||||
github: [rishighan]
|
||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<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">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
jsdoc.json
@@ -1,34 +1,25 @@
|
||||
{
|
||||
"tags": {
|
||||
"allowUnknownTags": true,
|
||||
"dictionaries": [
|
||||
"jsdoc",
|
||||
"closure"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"./src/client"
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"./src/client"
|
||||
],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"better-docs/component",
|
||||
"better-docs/category",
|
||||
"plugins/markdown",
|
||||
"node_modules/better-docs/typescript"
|
||||
],
|
||||
"templates": {
|
||||
"better-docs": {
|
||||
"name": "ThreeTwo UI components"
|
||||
"opts": {
|
||||
"template": "node_modules/tui-jsdoc-template",
|
||||
"encoding": "utf8",
|
||||
"destination": "docs/",
|
||||
"recurse": true,
|
||||
"verbose": true
|
||||
},
|
||||
"templates": {
|
||||
"cleverLinks": false,
|
||||
"monospaceLinks": false
|
||||
}
|
||||
},
|
||||
"opts": {
|
||||
"destination": "docs/",
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"encoding": "utf8",
|
||||
"verbose": true,
|
||||
"template": "node_modules/better-docs"
|
||||
}
|
||||
}
|
||||
211
package.json
@@ -1,185 +1,134 @@
|
||||
{
|
||||
"name": "threetwo",
|
||||
"version": "0.0.2",
|
||||
"description": "ThreeTwo! A comic book curator.",
|
||||
"version": "0.1.0",
|
||||
"description": "ThreeTwo! A good comic book curator.",
|
||||
"main": "server/index.js",
|
||||
"typings": "server/index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"start": "npm run build && npm run server",
|
||||
"client": "webpack serve --mode development --devtool inline-source-map --hot",
|
||||
"server": "tsc -p tsconfig.server.json && node server/",
|
||||
"dev": "concurrently \"nodemon\" \"npm run client\"",
|
||||
"server-dev": "nodemon",
|
||||
"build": "vite build",
|
||||
"dev": "rimraf dist && npm run build && vite",
|
||||
"start": "npm run build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook"
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"author": "Rishi Ghan",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.17",
|
||||
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
||||
"@dnd-kit/core": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||
"@parcel/config-default": "^2.6.0",
|
||||
"@redux-devtools/extension": "^3.2.2",
|
||||
"@tanstack/react-table": "^8.5.11",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@floating-ui/react": "^0.26.12",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@tanstack/react-query": "^5.0.5",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/sharp": "^0.28.0",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"airdcpp-apisocket": "^2.4.4",
|
||||
"array-sort-by": "^1.2.1",
|
||||
"axios": "^0.27.2",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||
"axios": "^1.7.4",
|
||||
"axios-cache-interceptor": "^1.0.1",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"axios-simple-cache-adapter": "^1.1.0",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"babel-preset-minify": "^0.5.2",
|
||||
"better-docs": "^2.7.2",
|
||||
"comlink-loader": "^2.0.0",
|
||||
"compromise": "^13.11.3",
|
||||
"compromise-dates": "^2.2.1",
|
||||
"compromise-numbers": "^1.4.0",
|
||||
"compromise-sentences": "^0.3.0",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.1.0",
|
||||
"express": "^4.17.1",
|
||||
"fastest-validator": "^1.11.0",
|
||||
"ellipsize": "^0.5.1",
|
||||
"express": "^4.20.0",
|
||||
"filename-parser": "^1.0.2",
|
||||
"final-form": "^4.20.2",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"history": "^5.3.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",
|
||||
"keen-slider": "^6.8.6",
|
||||
"lodash": "^4.17.21",
|
||||
"opds-extra": "^3.0.9",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^18.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.3.5",
|
||||
"react-day-picker": "^8.0.6",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"react-final-form-arrays": "^3.1.3",
|
||||
"react-final-form-arrays": "^3.1.4",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loader-spinner": "^4.0.0",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router": "^6.2.2",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-select": "^5.3.2",
|
||||
"react-select-async-paginate": "^0.6.1",
|
||||
"react-slick": "^0.29.0",
|
||||
"react-router": "^6.9.0",
|
||||
"react-router-dom": "^6.9.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select-async-paginate": "^0.7.2",
|
||||
"react-sliding-pane": "^7.1.0",
|
||||
"react-stickynode": "^4.0.0",
|
||||
"react-textarea-autosize": "^8.3.4",
|
||||
"reapop": "^4.0.5",
|
||||
"redux-first-history": "^5.0.9",
|
||||
"redux-socket.io-middleware": "^1.0.4",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"sharp": "^0.30.5",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"react-toastify": "^10.0.5",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^5.3.5",
|
||||
"styled-components": "^6.1.0",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"voca": "^1.4.0",
|
||||
"vite": "^5.2.14",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"ws": "^7.5.3",
|
||||
"xml2js": "^0.4.23",
|
||||
"xregexp": "^5.0.2"
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/plugin-syntax-top-level-await": "^7.14.5",
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.13.10",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
|
||||
"@storybook/addon-actions": "^6.5.9",
|
||||
"@storybook/addon-essentials": "^6.5.9",
|
||||
"@storybook/addon-interactions": "^6.5.9",
|
||||
"@storybook/addon-links": "^6.5.9",
|
||||
"@storybook/builder-webpack4": "^6.5.9",
|
||||
"@storybook/builder-webpack5": "^6.5.9",
|
||||
"@storybook/manager-webpack4": "^6.5.9",
|
||||
"@storybook/manager-webpack5": "^6.5.9",
|
||||
"@storybook/preset-scss": "^1.0.3",
|
||||
"@storybook/react": "^6.5.9",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||
"@iconify-json/solar": "^1.1.8",
|
||||
"@iconify/tailwind": "^0.1.4",
|
||||
"@storybook/addon-essentials": "^7.4.1",
|
||||
"@storybook/addon-interactions": "^7.4.1",
|
||||
"@storybook/addon-links": "^7.4.1",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.4.1",
|
||||
"@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",
|
||||
"@types/ellipsize": "^0.1.1",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"babel-eslint": "^10.0.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"body-parser": "^1.19.0",
|
||||
"buffer": "^6.0.3",
|
||||
"bulma": "^0.9.3",
|
||||
"clean-webpack-plugin": "^1.0.0",
|
||||
"comlink": "^4.3.0",
|
||||
"compromise-strict": "^0.0.2",
|
||||
"concurrently": "^4.0.0",
|
||||
"copy-webpack-plugin": "^9.0.1",
|
||||
"css-loader": "^5.1.2",
|
||||
"eslint": "^7.22.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"docdash": "^2.0.2",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsdoc": "^46.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^9.1.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"http-response-stream": "^1.0.9",
|
||||
"image-webpack-loader": "^8.1.0",
|
||||
"eslint-plugin-storybook": "^0.6.13",
|
||||
"express": "^4.20.0",
|
||||
"install": "^0.13.0",
|
||||
"jest": "^26.6.3",
|
||||
"mini-css-extract-plugin": "^1.4.1",
|
||||
"node-sass": "^7.0.1",
|
||||
"nodemon": "^1.17.3",
|
||||
"npm": "^8.11.0",
|
||||
"jest": "^29.6.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-import": "^15.1.0",
|
||||
"prettier": "^2.2.1",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"sass-loader": "^11.0.1",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"string-similarity": "^4.0.4",
|
||||
"style-loader": "^2.0.0",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.2.3",
|
||||
"url-loader": "^1.0.1",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^4.6.0",
|
||||
"webpack-dev-server": "^4.7.4",
|
||||
"webpack-merge": "^5.7.3"
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.77.0",
|
||||
"storybook": "^7.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"@storybook/react/webpack": "^5"
|
||||
"jackspeak": "2.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/Hasklig-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Heavy.otf
Normal file
BIN
public/fonts/PPObjectSans-HeavySlanted.otf
Normal file
BIN
public/fonts/PPObjectSans-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Slanted.otf
Normal file
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
screenshots/CVMatching.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
screenshots/ComicDetail.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
screenshots/DC++Searching.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
screenshots/Dashboard.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/Import.jpg
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
screenshots/Library.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
@@ -4,7 +4,10 @@ import {
|
||||
PriorityEnum,
|
||||
SearchResponse,
|
||||
} from "threetwo-ui-typings";
|
||||
import { LIBRARY_SERVICE_BASE_URI, SEARCH_SERVICE_BASE_URI } from "../constants/endpoints";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SEARCH_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
import {
|
||||
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||
@@ -18,6 +21,8 @@ import {
|
||||
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
AIRDCPP_TRANSFERS_FETCHED,
|
||||
LIBRARY_ISSUE_BUNDLES,
|
||||
AIRDCPP_SOCKET_CONNECTED,
|
||||
AIRDCPP_SOCKET_DISCONNECTED,
|
||||
} from "../constants/action-types";
|
||||
import { isNil } from "lodash";
|
||||
import axios from "axios";
|
||||
@@ -28,194 +33,126 @@ interface SearchData {
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<NodeJS.Timeout> {
|
||||
export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
};
|
||||
|
||||
export const search =
|
||||
(data: SearchData, ADCPPSocket: any, credentials: any) =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect(
|
||||
credentials.username,
|
||||
credentials.password,
|
||||
true,
|
||||
);
|
||||
}
|
||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||
export const toggleAirDCPPSocketConnectionStatus =
|
||||
(status: String, payload?: any) => async (dispatch) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
dispatch({
|
||||
type: AIRDCPP_SEARCH_IN_PROGRESS,
|
||||
type: AIRDCPP_SOCKET_CONNECTED,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
case "disconnected":
|
||||
dispatch({
|
||||
type: AIRDCPP_SOCKET_DISCONNECTED,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Can't set AirDC++ socket status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
export const downloadAirDCPPItem =
|
||||
(searchInstanceId: Number,
|
||||
(
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
comicObjectId: String,
|
||||
name: String, size: Number, type: any,
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
ADCPPSocket: any,
|
||||
credentials: any,
|
||||
): void =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect(
|
||||
`${credentials.username}`,
|
||||
`${credentials.password}`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let bundleDBImportResult = {};
|
||||
const downloadResult = await ADCPPSocket.post(
|
||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||
);
|
||||
|
||||
if (!isNil(downloadResult)) {
|
||||
bundleDBImportResult = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyAirDCPPDownloadMetadata`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
bundleId: downloadResult.bundle_info.id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
downloadResult,
|
||||
bundleDBImportResult,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
comicBookDetail: bundleDBImportResult.data,
|
||||
IMS_inProgress: false,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
};
|
||||
let bundleDBImportResult = {};
|
||||
const downloadResult = await ADCPPSocket.post(
|
||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||
);
|
||||
|
||||
export const getBundlesForComic =
|
||||
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect(
|
||||
`${credentials.username}`,
|
||||
`${credentials.password}`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
const comicObject = await axios({
|
||||
if (!isNil(downloadResult)) {
|
||||
bundleDBImportResult = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyAirDCPPDownloadMetadata`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
id: `${comicObjectId}`,
|
||||
bundleId: downloadResult.bundle_info.id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
},
|
||||
});
|
||||
// get only the bundles applicable for the comic
|
||||
if (comicObject.data.acquisition.directconnect) {
|
||||
const filteredBundles = comicObject.data.acquisition.directconnect.downloads.map(
|
||||
|
||||
dispatch({
|
||||
type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
downloadResult,
|
||||
bundleDBImportResult,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
comicBookDetail: bundleDBImportResult.data,
|
||||
IMS_inProgress: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBundlesForComic =
|
||||
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
const comicObject = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
id: `${comicObjectId}`,
|
||||
},
|
||||
});
|
||||
// get only the bundles applicable for the comic
|
||||
if (comicObject.data.acquisition.directconnect) {
|
||||
const filteredBundles =
|
||||
comicObject.data.acquisition.directconnect.downloads.map(
|
||||
async ({ bundleId }) => {
|
||||
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
|
||||
},
|
||||
);
|
||||
dispatch({
|
||||
type: AIRDCPP_BUNDLES_FETCHED,
|
||||
bundles: await Promise.all(filteredBundles),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
dispatch({
|
||||
type: AIRDCPP_BUNDLES_FETCHED,
|
||||
bundles: await Promise.all(filteredBundles),
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTransfers =
|
||||
(ADCPPSocket: any, credentials: any) => async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect(
|
||||
`${credentials.username}`,
|
||||
`${credentials.password}`,
|
||||
true,
|
||||
);
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
const bundles = await ADCPPSocket.get("queue/bundles/1/85", {});
|
||||
if (!isNil(bundles)) {
|
||||
@@ -234,7 +171,6 @@ export const getTransfers =
|
||||
type: LIBRARY_ISSUE_BUNDLES,
|
||||
issue_bundles,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import {
|
||||
AxiosCacheRequestConfig,
|
||||
createCacheAdapter,
|
||||
} from "axios-simple-cache-adapter";
|
||||
import qs from "qs";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
import {
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
@@ -26,25 +22,21 @@ import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
|
||||
const axiosCacheAdapter = createCacheAdapter();
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
maxRPS: 1,
|
||||
});
|
||||
|
||||
const cachedAxios = setupCache(axios);
|
||||
export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||
method: "get",
|
||||
params: options,
|
||||
axiosCacheAdapter,
|
||||
cache: 1000, // value in MS
|
||||
} as AxiosCacheRequestConfig).then((response) => {
|
||||
}).then((response) => {
|
||||
dispatch({
|
||||
type: CV_WEEKLY_PULLLIST_FETCHED,
|
||||
data: response.data.result,
|
||||
@@ -70,9 +62,6 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
paramsSerializer: (params) => {
|
||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
||||
},
|
||||
});
|
||||
|
||||
switch (options.callURIAction) {
|
||||
@@ -137,7 +126,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
|
||||
queryObjects,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
dispatch({
|
||||
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||
matches: foo.data,
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_HOST,
|
||||
SEARCH_SERVICE_BASE_URI,
|
||||
JOB_QUEUE_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
import {
|
||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||
IMS_RECENT_COMICS_FETCHED,
|
||||
IMS_WANTED_COMICS_FETCHED,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
@@ -22,23 +21,38 @@ import {
|
||||
LS_IMPORT,
|
||||
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_RESULTS_FETCHED,
|
||||
SS_SEARCH_IN_PROGRESS,
|
||||
FILEOPS_STATE_RESET,
|
||||
LS_IMPORT_CALL_IN_PROGRESS,
|
||||
LS_TOGGLE_IMPORT_QUEUE,
|
||||
SS_SEARCH_FAILED,
|
||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
WANTED_COMICS_FETCHED,
|
||||
VOLUMES_FETCHED,
|
||||
CV_WEEKLY_PULLLIST_FETCHED,
|
||||
LIBRARY_SERVICE_HEALTH,
|
||||
LS_SET_QUEUE_STATUS,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { success } from "react-notification-system-redux";
|
||||
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
|
||||
import { isNil, map } from "lodash";
|
||||
|
||||
import { isNil } 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>> {
|
||||
return axios
|
||||
.request<Array<IFolderData>>({
|
||||
@@ -60,15 +74,6 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||
* @return the comic book metadata
|
||||
*/
|
||||
export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||
const extractionOptions = {
|
||||
extractTarget: "cover",
|
||||
targetExtractionFolder: "./userdata/covers",
|
||||
extractionMode: "bulk",
|
||||
paginationOptions: {
|
||||
pageLimit: 25,
|
||||
page: 1,
|
||||
},
|
||||
};
|
||||
dispatch({
|
||||
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
@@ -83,19 +88,38 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||
// autoDismiss: 0,
|
||||
// }),
|
||||
// );
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
dispatch({
|
||||
type: LS_IMPORT,
|
||||
meta: { remote: true },
|
||||
data: { extractionOptions },
|
||||
});
|
||||
|
||||
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({
|
||||
type: LS_TOGGLE_IMPORT_QUEUE,
|
||||
meta: { remote: true },
|
||||
data: { manjhul: "jigyadam", action: options.action },
|
||||
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
data: result.data,
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
* @return metadata for the comic book object categories
|
||||
@@ -136,46 +160,50 @@ export const getComicBooks = (options) => async (dispatch) => {
|
||||
* @returns Nothing.
|
||||
* @param payload
|
||||
*/
|
||||
export const importToDB = (sourceName: string, payload?: any) => (dispatch) => {
|
||||
try {
|
||||
const comicBookMetadata = {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
export const importToDB =
|
||||
(sourceName: string, metadata?: any) => (dispatch) => {
|
||||
try {
|
||||
const comicBookMetadata = {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata: metadata || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
},
|
||||
},
|
||||
sourcedMetadata: payload || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
};
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
return axios
|
||||
.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,
|
||||
});
|
||||
};
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_FAILED,
|
||||
importError: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
return axios
|
||||
.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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchVolumeGroups = () => async (dispatch) => {
|
||||
try {
|
||||
@@ -256,36 +284,34 @@ export const fetchComicVineMatches =
|
||||
/**
|
||||
* This method is a proxy to `uncompressFullArchive` which uncompresses complete `rar` or `zip` archives
|
||||
* @param {string} path The path to the compressed archive
|
||||
* @param {any} options Options object
|
||||
* @param {any} options Options object
|
||||
* @returns {any}
|
||||
*/
|
||||
export const extractComicArchive = (path: string, options: any) => async (dispatch) => {
|
||||
const comicBookPages: string[] = [];
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
});
|
||||
const extractedComicBookArchive = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
filePath: path,
|
||||
},
|
||||
});
|
||||
map(extractedComicBookArchive.data, (page) => {
|
||||
console.log(page);
|
||||
const pageFilePath = removeLeadingPeriod(page);
|
||||
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
|
||||
comicBookPages.push(imagePath);
|
||||
});
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
||||
extractedComicBookArchive: comicBookPages,
|
||||
});
|
||||
};
|
||||
export const extractComicArchive =
|
||||
(path: string, options: any): any =>
|
||||
async (dispatch) => {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
});
|
||||
await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
filePath: path,
|
||||
options,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Description
|
||||
* @param {any} query
|
||||
* @param {any} options
|
||||
* @returns {any}
|
||||
*/
|
||||
export const searchIssue = (query, options) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: SS_SEARCH_IN_PROGRESS,
|
||||
@@ -308,26 +334,26 @@ export const searchIssue = (query, options) => async (dispatch) => {
|
||||
case "wantedComicsPage":
|
||||
dispatch({
|
||||
type: WANTED_COMICS_FETCHED,
|
||||
data: response.data.body,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
case "globalSearchBar":
|
||||
dispatch({
|
||||
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
data: response.data.body,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
|
||||
case "libraryPage":
|
||||
dispatch({
|
||||
type: SS_SEARCH_RESULTS_FETCHED,
|
||||
data: response.data.body,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
case "volumesPage":
|
||||
dispatch({
|
||||
type: VOLUMES_FETCHED,
|
||||
data: response.data.body,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
|
||||
|
||||
@@ -3,25 +3,14 @@ import {
|
||||
SETTINGS_OBJECT_FETCHED,
|
||||
SETTINGS_CALL_IN_PROGRESS,
|
||||
SETTINGS_DB_FLUSH_SUCCESS,
|
||||
} from "../constants/action-types";
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||
} from "../reducers/settings.reducer";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SETTINGS_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
} 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) => {
|
||||
const result = await axios({
|
||||
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
||||
@@ -67,3 +56,22 @@ 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) => {};
|
||||
|
||||
@@ -1,627 +1,15 @@
|
||||
@import "../../../../node_modules/bulma/bulma.sass";
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
@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;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
$volume-color: #fdecd1;
|
||||
$issue-color: #f2f1f9;
|
||||
$size-8: 0.9rem;
|
||||
$size-9: 0.7rem;
|
||||
$flexSize: 4em;
|
||||
$boxSpacing: 1em;
|
||||
$colorText: #404646;
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
}
|
||||
|
||||
.is-size-8 {
|
||||
font-size: $size-8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,154 +0,0 @@
|
||||
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;
|
||||
@@ -1,116 +1,16 @@
|
||||
import React, { ReactElement, useContext, useEffect } from "react";
|
||||
import Dashboard from "./Dashboard/Dashboard";
|
||||
|
||||
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 React, { ReactElement } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../assets/scss/App.scss";
|
||||
import {
|
||||
AirDCPPSocketContextProvider,
|
||||
AirDCPPSocketContext,
|
||||
} from "../context/AirDCPPSocket";
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import { AIRDCPP_DOWNLOAD_PROGRESS_TICK } from "../constants/action-types";
|
||||
import { useDispatch } 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) => {
|
||||
if (bundleData.status.completed && bundleData.status.downloaded) {
|
||||
// dispatch the action for raw import, with the metadata
|
||||
console.log("IM THE MAN UP IN THIS")
|
||||
}
|
||||
},
|
||||
);
|
||||
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 => {
|
||||
return (
|
||||
<AirDCPPSocketContextProvider>
|
||||
<div>
|
||||
<AirDCPPSocketComponent />
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/import" element={<Import path={"./comics"} />} />
|
||||
<Route path="/library" element={<TabulatedContentContainer category="library" />} />
|
||||
<Route path="/library-grid" element={<LibraryGrid />} />
|
||||
<Route path="/downloads" element={<Downloads data={{}} />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route
|
||||
path={"/comic/details/:comicObjectId"}
|
||||
element={<ComicDetailContainer />}
|
||||
/>
|
||||
<Route
|
||||
path={"/volume/details/:comicObjectId"}
|
||||
element={<VolumeDetail />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/pull-list/all" element={<TabulatedContentContainer category="pullList" />} />
|
||||
<Route path="/wanted/all" element={<TabulatedContentContainer category="wanted" />} />
|
||||
<Route path="/volumes/all" element={<TabulatedContentContainer category="volumes" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</AirDCPPSocketContextProvider>
|
||||
<>
|
||||
<Navbar2 />
|
||||
<Outlet />
|
||||
<ToastContainer stacked hideProgressBar />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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;
|
||||
@@ -1,92 +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): 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;
|
||||
@@ -1,21 +1,16 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
search,
|
||||
downloadAirDCPPItem,
|
||||
getBundlesForComic,
|
||||
} from "../../actions/airdcpp.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { difference } from "../../shared/utils/object.utils";
|
||||
import { isEmpty, isNil, map } from "lodash";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
import { useStore } from "../../store";
|
||||
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 {
|
||||
query: any;
|
||||
comicObjectId: any;
|
||||
@@ -26,303 +21,461 @@ interface IAcquisitionPanelProps {
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): 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 { settings } = props;
|
||||
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 [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(() => {
|
||||
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(
|
||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||
(item) => item.value,
|
||||
),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs?.data, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Method to perform a search via an AirDC++ websocket
|
||||
* @param {SearchData} data - a SearchData query
|
||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||
*/
|
||||
const search = async (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;
|
||||
}
|
||||
}, [airDCPPConfiguration]);
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
});
|
||||
|
||||
const getDCPPSearchResults = useCallback(
|
||||
async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(
|
||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||
(item) => item.value,
|
||||
),
|
||||
priority: 5,
|
||||
};
|
||||
dispatch(
|
||||
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
|
||||
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, airDCPPConfiguration],
|
||||
);
|
||||
socketIOInstance.on("searchInitiated", (data) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
});
|
||||
socketIOInstance.on("searchesSent", (data) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
});
|
||||
|
||||
/**
|
||||
* Method to download a bundle associated with a search result from AirDC++
|
||||
* @param {Number} searchInstanceId - description
|
||||
* @param {String} resultId - description
|
||||
* @param {String} comicObjectId - description
|
||||
* @param {String} name - description
|
||||
* @param {Number} size - description
|
||||
* @param {any} type - description
|
||||
* @param {any} config - description
|
||||
* @returns {void} - description
|
||||
*/
|
||||
const download = async (
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
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 (
|
||||
<>
|
||||
<div className="comic-detail columns">
|
||||
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
|
||||
<div className="mt-5 mb-3">
|
||||
{!isEmpty(hubs?.data) ? (
|
||||
<Form
|
||||
onSubmit={getDCPPSearchResults}
|
||||
initialValues={{
|
||||
issueName,
|
||||
}}
|
||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="column is-three-quarters"
|
||||
>
|
||||
<div className="box search">
|
||||
<div className="columns">
|
||||
<Field name="issueName">
|
||||
{({ 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>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="issueName">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<div className="max-w-fit">
|
||||
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
<input
|
||||
{...input}
|
||||
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
|
||||
<div className="column">
|
||||
<button
|
||||
type="submit"
|
||||
className={
|
||||
isAirDCPPSearchInProgress
|
||||
? "button is-loading is-warning"
|
||||
: "button"
|
||||
}
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<img src="/img/airdcpp_logo.svg" />
|
||||
</span>
|
||||
<span className="airdcpp-text">Search on AirDC++</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 DC++
|
||||
<div className="h-5 w-5 ml-2">
|
||||
<img
|
||||
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<article
|
||||
role="alert"
|
||||
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"
|
||||
>
|
||||
No AirDC++ hub configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Hubs</code>.
|
||||
</article>
|
||||
)}
|
||||
</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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* AirDC++ results */}
|
||||
<div className="columns">
|
||||
<div className="">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
<div className="column">
|
||||
<table className="table">
|
||||
<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>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Slots</th>
|
||||
<th>Actions</th>
|
||||
<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">
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(result.dupe) ? "dupe-search-result" : ""
|
||||
}
|
||||
>
|
||||
<td>
|
||||
<p className="mb-2">
|
||||
{result.type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}{" "}
|
||||
{ellipsize(result.name, 70)}
|
||||
</p>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||
{map(
|
||||
airDCPPSearchResults,
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(dupe)
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "w-fit text-sm"
|
||||
}
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
||||
<p className="mb-2">
|
||||
{type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}
|
||||
{ellipsize(name, 70)}
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="tags">
|
||||
{!isNil(result.dupe) ? (
|
||||
<span className="tag is-warning">Dupe</span>
|
||||
) : null}
|
||||
<span className="tag is-light is-info">
|
||||
{result.users.user.nicks}
|
||||
</span>
|
||||
{result.users.user.flags.map((flag, idx) => (
|
||||
<span className="tag is-light" key={idx}>
|
||||
{flag}
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-row gap-2">
|
||||
{!isNil(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="pr-1 pt-1">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
Dupe
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
<span className="tag is-light is-info">
|
||||
{result.type.id === "directory"
|
||||
? "directory"
|
||||
: result.type.str}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-success">
|
||||
{result.slots.free} free
|
||||
{/* Flags */}
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{flag}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<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 className="tag is-light">
|
||||
{result.slots.total}
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{/* 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>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
onClick={() =>
|
||||
downloadDCPPResult(
|
||||
searchInstance.id,
|
||||
result.id,
|
||||
result.name,
|
||||
result.size,
|
||||
result.type,
|
||||
)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-file-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
<td className="px-2">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
<div className="">
|
||||
<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>
|
||||
|
||||
<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{" "}
|
||||
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
||||
or may not work at all. Searches from <code>ADCS</code> hubs are
|
||||
more reliable than <code>NMDCS</code> ones.
|
||||
or may not work at all. Searches from{" "}
|
||||
<code className="font-hasklig">ADCS</code> hubs are more
|
||||
reliable than <code className="font-hasklig">NMDCS</code> ones.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,87 +1,26 @@
|
||||
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
||||
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";
|
||||
import React, { ReactElement } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
export const Menu = (props): ReactElement => {
|
||||
const { data } = props;
|
||||
const { setSlidingPanelContentId, setVisible } = props.handlers;
|
||||
const dispatch = useDispatch();
|
||||
const openDrawerWithCVMatches = useCallback(() => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
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;
|
||||
}
|
||||
};
|
||||
const {
|
||||
filteredActionOptions,
|
||||
customStyles,
|
||||
handleActionSelection,
|
||||
Placeholder,
|
||||
} = props.configuration;
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="basic-single"
|
||||
classNamePrefix="select"
|
||||
components={{ Placeholder }}
|
||||
placeholder={
|
||||
<span>
|
||||
<i className="fa-solid fa-list"></i> Actions
|
||||
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Select An Action</div>
|
||||
</span>
|
||||
}
|
||||
styles={customStyles}
|
||||
name="actions"
|
||||
isSearchable={false}
|
||||
options={filteredActionOptions}
|
||||
|
||||
51
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import ellipsize from "ellipsize";
|
||||
import { map } from "lodash";
|
||||
|
||||
export const AirDCPPBundles = (props) => {
|
||||
return (
|
||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Filename
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Size
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Download Time
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Bundle ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{map(props.data, (bundle) => (
|
||||
<tr key={bundle.id} className="text-sm">
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||
<span className="text-xs">{ellipsize(bundle.target, 88)}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{prettyBytes(bundle.size)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{dayjs
|
||||
.unix(bundle.time_finished)
|
||||
.format("h:mm on ddd, D MMM, YYYY")}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">{bundle.id}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||
import Creatable from "react-select/creatable";
|
||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||
|
||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
interface AsyncSelectPaginateProps {
|
||||
metronResource: string;
|
||||
placeholder?: string;
|
||||
value?: object;
|
||||
onChange?(...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||
const [value, setValue] = useState(null);
|
||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||
|
||||
@@ -38,11 +44,4 @@ export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
AsyncSelectPaginate.propTypes = {
|
||||
metronResource: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default AsyncSelectPaginate;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
|
||||
import { RawFileDetails } from "./RawFileDetails";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||
|
||||
import TabControls from "./TabControls";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
@@ -13,10 +12,12 @@ import { Menu } from "./ActionMenu/Menu";
|
||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||
import AcquisitionPanel from "./AcquisitionPanel";
|
||||
import TorrentSearchPanel from "./TorrentSearchPanel";
|
||||
import DownloadsPanel from "./DownloadsPanel";
|
||||
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||
|
||||
import { isEmpty, isUndefined, isNil } from "lodash";
|
||||
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||
import { components } from "react-select";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
|
||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||
@@ -28,6 +29,10 @@ import ComicViewer from "react-comic-viewer";
|
||||
|
||||
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||
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 = {};
|
||||
/**
|
||||
@@ -47,6 +52,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||
acquisition,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
userSettings,
|
||||
} = data;
|
||||
@@ -54,33 +62,33 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
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,
|
||||
);
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
const openModal = useCallback((filePath) => {
|
||||
setIsOpen(true);
|
||||
dispatch(extractComicArchive(filePath));
|
||||
// dispatch(
|
||||
// extractComicArchive(filePath, {
|
||||
// type: "full",
|
||||
// purpose: "reading",
|
||||
// imageResizeOptions: {
|
||||
// baseWidth: 1024,
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
}, []);
|
||||
|
||||
// overridden <SlidingPanel> with some styles
|
||||
const StyledSlidingPanel = styled(SlidingPane)`
|
||||
background: #ccc;
|
||||
`;
|
||||
const afterOpenModal = useCallback((things) => {
|
||||
// references are now sync'd and can be accessed.
|
||||
// subtitle.style.color = "#f00";
|
||||
console.log(things);
|
||||
console.log("kolaveri", things);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
@@ -92,47 +100,175 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
CVMatches: {
|
||||
content: (props) => (
|
||||
<>
|
||||
<div className="card search-criteria-card">
|
||||
<div className="card-content">
|
||||
<ComicVineSearchForm data={rawFileDetails}/>
|
||||
</div>
|
||||
<div>
|
||||
<ComicVineSearchForm data={rawFileDetails} />
|
||||
</div>
|
||||
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<div className="ml-3">
|
||||
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
|
||||
<span className="tag"> # {inferredMetadata.issue.number} </span>
|
||||
</div>
|
||||
) : null}
|
||||
{!comicVineAPICallProgress ? (
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineSearchQueryObject,
|
||||
comicVineAPICallProgress,
|
||||
comicVineSearchResults,
|
||||
comicObjectId,
|
||||
}}
|
||||
/>) : (<div className="progress-indicator-container" >
|
||||
<div className="indicator">
|
||||
<Loader
|
||||
type="MutatingDots"
|
||||
color="#CCC"
|
||||
secondaryColor="#999"
|
||||
height={100}
|
||||
width={100}
|
||||
visible={comicVineAPICallProgress}
|
||||
/>
|
||||
</div>
|
||||
</div >)}
|
||||
</>),
|
||||
},
|
||||
|
||||
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||
<p className="">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<>
|
||||
<span className="">{inferredMetadata.issue.name} </span>
|
||||
<span className=""> # {inferredMetadata.issue.number} </span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
editComicBookMetadata: {
|
||||
content: () => <EditMetadataPanel />,
|
||||
content: () => <EditMetadataPanel data={rawFileDetails} />,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Actions
|
||||
|
||||
const fetchComicVineMatches = async (
|
||||
searchPayload,
|
||||
issueSearchQuery,
|
||||
seriesSearchQuery,
|
||||
) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
// hack
|
||||
query: issueSearchQuery.inferredIssueDetails.name
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim(),
|
||||
limit: "100",
|
||||
page: 1,
|
||||
resources: "volume",
|
||||
scorerConfiguration: {
|
||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||
},
|
||||
rawFileDetails: searchPayload,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
// return sortBy(matches, (match) => -match.score);
|
||||
},
|
||||
});
|
||||
let matches: any = [];
|
||||
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||
matches = response.data.results;
|
||||
} else {
|
||||
matches = response.data.map((match) => match);
|
||||
}
|
||||
const scoredMatches = matches.sort((a, b) => b.score - a.score);
|
||||
setComicVineMatches(scoredMatches);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
|
||||
if (!isUndefined(rawFileDetails)) {
|
||||
issueSearchQuery = refineQuery(rawFileDetails.name);
|
||||
} else if (!isEmpty(comicvine)) {
|
||||
issueSearchQuery = refineQuery(comicvine.name);
|
||||
}
|
||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const openEditMetadataPanel = useCallback(() => {
|
||||
setSlidingPanelContentId("editComicBookMetadata");
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// Actions menu options and handler
|
||||
const CVMatchLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Match on ComicVine</div>
|
||||
</span>
|
||||
);
|
||||
const editLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Edit Metadata</div>
|
||||
</span>
|
||||
);
|
||||
const deleteLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Delete Comic</div>
|
||||
</span>
|
||||
);
|
||||
const Placeholder = (props) => {
|
||||
return <components.Placeholder {...props} />;
|
||||
};
|
||||
const actionOptions = [
|
||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||
{ value: "edit-metdata", label: editLabel },
|
||||
{ value: "delete-comic", label: deleteLabel },
|
||||
];
|
||||
|
||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||
if (isUndefined(rawFileDetails)) {
|
||||
return item.value !== "match-on-comic-vine";
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const handleActionSelection = (action) => {
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
break;
|
||||
case "edit-metdata":
|
||||
openEditMetadataPanel();
|
||||
break;
|
||||
default:
|
||||
console.log("No valid action selected.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
const customStyles = {
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
}),
|
||||
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
paddingTop: "0.4rem",
|
||||
}),
|
||||
control: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
color: "black",
|
||||
border: "1px solid rgb(156, 163, 175)",
|
||||
}),
|
||||
};
|
||||
|
||||
// check for the availability of CV metadata
|
||||
const isComicBookMetadataAvailable =
|
||||
@@ -140,7 +276,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
// check for the availability of rawFileDetails
|
||||
const areRawFileDetailsAvailable =
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails,
|
||||
@@ -160,7 +296,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
{
|
||||
id: 1,
|
||||
name: "Volume Information",
|
||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||
),
|
||||
content: isComicBookMetadataAvailable ? (
|
||||
<VolumeInformation data={data.data} key={1} />
|
||||
) : null,
|
||||
@@ -169,27 +307,31 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
{
|
||||
id: 2,
|
||||
name: "ComicInfo.xml",
|
||||
icon: <i className="fa-solid fa-code"></i>,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
content: (
|
||||
<div className="columns" key={2}>
|
||||
<div className="column is-three-quarters">
|
||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||
</div>
|
||||
<div key={2}>
|
||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||
</div>
|
||||
),
|
||||
shouldShow: !isEmpty(comicInfo),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <i className="fa-regular fa-file-archive"></i>,
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "Archive Operations",
|
||||
content: <ArchiveOperations data={data.data} key={3} />,
|
||||
shouldShow: areRawFileDetailsAvailable,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: <i className="fa-solid fa-floppy-disk"></i>,
|
||||
name: "Acquisition",
|
||||
icon: (
|
||||
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
),
|
||||
name: "DC++ Search",
|
||||
content: (
|
||||
<AcquisitionPanel
|
||||
query={airDCPPQuery}
|
||||
@@ -203,19 +345,37 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: null,
|
||||
name: !isEmpty(data.data) ? (
|
||||
<span className="download-tab-name">Downloads</span>
|
||||
) : (
|
||||
"Downloads"
|
||||
icon: (
|
||||
<span className="inline-flex flex-row">
|
||||
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
</span>
|
||||
),
|
||||
content: !isNil(data.data) && !isEmpty(data.data) && (
|
||||
<DownloadsPanel
|
||||
data={data.data.acquisition.directconnect}
|
||||
comicObjectId={comicObjectId}
|
||||
key={5}
|
||||
/>
|
||||
name: "Torrent Search",
|
||||
content: <TorrentSearchPanel comicObjectId={_id} issueName={issueName} />,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -227,70 +387,74 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
// 2. from the CV-scraped version
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<section className="container mx-auto">
|
||||
<div className="section">
|
||||
{!isNil(data) && !isEmpty(data) && (
|
||||
<>
|
||||
<h1 className="title">{issueName}</h1>
|
||||
<div className="columns is-multiline">
|
||||
<div className="column is-narrow">
|
||||
<div>
|
||||
<div className="flex flex-row mt-5">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"vertical"}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
cardContainerStyle={{ maxWidth: 275 }}
|
||||
/>
|
||||
{/* action dropdown */}
|
||||
<div className="mt-4 is-size-7">
|
||||
<Menu
|
||||
data={data.data}
|
||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* raw file details */}
|
||||
<div className="column is-three-fifths">
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
<>
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
rawFileDetails: rawFileDetails,
|
||||
inferredMetadata: inferredMetadata,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
}}
|
||||
/>
|
||||
{/* Read comic button */}
|
||||
<button
|
||||
className="button is-success is-light"
|
||||
onClick={() => openModal(rawFileDetails.filePath)}
|
||||
>
|
||||
<i className="fa-solid fa-book-open mr-2"></i>
|
||||
Read
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
style={{ content: { marginTop: "2rem" } }}
|
||||
isOpen={modalIsOpen}
|
||||
onAfterOpen={afterOpenModal}
|
||||
onRequestClose={closeModal}
|
||||
contentLabel="Example Modal"
|
||||
>
|
||||
<button onClick={closeModal}>close</button>
|
||||
{extractedComicBook && (
|
||||
<ComicViewer
|
||||
pages={extractedComicBook}
|
||||
direction="ltr"
|
||||
{/* action dropdown */}
|
||||
<div className="mt-1 flex flex-row gap-2 w-full">
|
||||
<Menu
|
||||
data={data.data}
|
||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||
configuration={{
|
||||
filteredActionOptions,
|
||||
customStyles,
|
||||
handleActionSelection,
|
||||
Placeholder,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</RawFileDetails>
|
||||
|
||||
{/* <Modal
|
||||
style={{ content: { marginTop: "2rem" } }}
|
||||
isOpen={modalIsOpen}
|
||||
onAfterOpen={afterOpenModal}
|
||||
onRequestClose={closeModal}
|
||||
contentLabel="Example Modal"
|
||||
>
|
||||
<button onClick={closeModal}>close</button>
|
||||
{extractedComicBook && (
|
||||
<ComicViewer
|
||||
pages={extractedComicBook}
|
||||
direction="ltr"
|
||||
className={{
|
||||
closeButton: "border: 1px solid red;",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{<TabControls filteredTabs={filteredTabs} />}
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length}
|
||||
/>
|
||||
|
||||
<SlidingPane
|
||||
<StyledSlidingPanel
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Comic Vine Search Matches"}
|
||||
@@ -298,7 +462,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</StyledSlidingPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import React, { ReactElement } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
|
||||
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 => {
|
||||
const comicBookDetailData = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
useEffect(() => {
|
||||
dispatch(getComicBookDetailById(comicObjectId));
|
||||
// dispatch(getSettings());
|
||||
}, [dispatch]);
|
||||
return !isEmpty(comicBookDetailData) ? (
|
||||
<ComicDetail data={comicBookDetailData} />
|
||||
) : null;
|
||||
const {
|
||||
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} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,119 +1,118 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import dayjs from "dayjs";
|
||||
import { isUndefined } from "lodash";
|
||||
import Card from "../Carda";
|
||||
export const ComicVineDetails = (props): ReactElement => {
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import { convert } from "html-to-text";
|
||||
|
||||
interface ComicVineDetailsProps {
|
||||
updatedAt?: string;
|
||||
data?: {
|
||||
name?: string;
|
||||
number?: string;
|
||||
resource_type?: string;
|
||||
id?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||
const { data, updatedAt } = props;
|
||||
return (
|
||||
<div className="column is-two-thirds">
|
||||
<div className="comic-detail comicvine-metadata">
|
||||
<dl>
|
||||
<dt>ComicVine Metadata</dt>
|
||||
<dd className="is-size-7">
|
||||
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
<div className="columns mt-2">
|
||||
<div className="column is-2">
|
||||
<Card
|
||||
imageUrl={data.volumeInformation.image.thumb_url}
|
||||
orientation={"vertical"}
|
||||
hasDetails={false}
|
||||
// cardContainerStyle={{ maxWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-10">
|
||||
<dl>
|
||||
<dt>
|
||||
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
|
||||
</dt>
|
||||
<dd>
|
||||
Is a part of{" "}
|
||||
<span className="has-text-info">
|
||||
{data.volumeInformation.name}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
Published by
|
||||
<span className="has-text-weight-semibold">
|
||||
{" "}
|
||||
{data.volumeInformation.publisher.name}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
Total issues in this volume:
|
||||
{data.volumeInformation.count_of_issues}
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
<div className="field is-grouped mt-2">
|
||||
{data.issue_number && (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Issue Number</span>
|
||||
<span className="tag is-warning">
|
||||
{data.issue_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(
|
||||
detectIssueTypes(data.volumeInformation.description),
|
||||
) ? (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Detected Type</span>
|
||||
<span className="tag is-warning">
|
||||
{
|
||||
detectIssueTypes(
|
||||
data.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : data.resource_type ? (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Type</span>
|
||||
<span className="tag is-warning">
|
||||
{data.resource_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">
|
||||
ComicVine Issue ID
|
||||
</span>
|
||||
<span className="tag is-success">{data.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-gray-400">
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="min-w-fit">
|
||||
<Card
|
||||
imageUrl={data.volumeInformation.image.thumb_url}
|
||||
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>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Comicvine metadata */}
|
||||
<div className="mt-2">
|
||||
<div className="text-md">ComicVine Metadata</div>
|
||||
<div className="text-sm">
|
||||
Last scraped on{" "}
|
||||
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
ComicVine Issue ID
|
||||
<span>{data.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publisher details */}
|
||||
<div className="ml-8">
|
||||
Published by{" "}
|
||||
<span>{data.volumeInformation.publisher.name}</span>
|
||||
<div>
|
||||
Total issues in this volume{" "}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{data.volumeInformation.count_of_issues}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{data.issue_number && (
|
||||
<div className="">
|
||||
<span>Issue Number</span>
|
||||
<span>{data.issue_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(
|
||||
detectIssueTypes(data.volumeInformation.description),
|
||||
) ? (
|
||||
<div>
|
||||
<span>Detected Type</span>
|
||||
<span>
|
||||
{
|
||||
detectIssueTypes(data.volumeInformation.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
) : data.resource_type ? (
|
||||
<div>
|
||||
<span>Type</span>
|
||||
<span>{data.resource_type}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mt-3 w-3/4">
|
||||
{!isEmpty(data.description) &&
|
||||
convert(data.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComicVineDetails;
|
||||
|
||||
ComicVineDetails.propTypes = {
|
||||
updatedAt: PropTypes.string,
|
||||
data: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.string,
|
||||
resource_type: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
import MatchResult from "../MatchResult";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
const {
|
||||
comicObjectId,
|
||||
comicVineSearchQueryObject,
|
||||
comicVineAPICallProgress,
|
||||
comicVineSearchResults,
|
||||
} = comicVineData.props;
|
||||
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||
const { comicvine } = useStore(
|
||||
useShallow((state) => ({
|
||||
comicvine: state.comicvine,
|
||||
})),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="search-results-container">
|
||||
{!isEmpty(comicVineSearchResults) && (
|
||||
<div>
|
||||
{!isEmpty(comicVineMatches) ? (
|
||||
<MatchResult
|
||||
matchData={comicVineSearchResults}
|
||||
matchData={comicVineMatches}
|
||||
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>
|
||||
</>
|
||||
|
||||
96
src/client/components/ComicDetail/ComicVineSearchForm.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
@@ -3,10 +3,10 @@ import React, { ReactElement } from "react";
|
||||
|
||||
export const DownloadProgressTick = (props): ReactElement => {
|
||||
return (
|
||||
<div >
|
||||
<h4 className="is-size-6">{props.data.name}</h4>
|
||||
<div>
|
||||
<h4 className="is-size-5">{props.data.name}</h4>
|
||||
<div>
|
||||
<span className="is-size-3 has-text-weight-semibold">
|
||||
<span className="is-size-4 has-text-weight-semibold">
|
||||
{prettyBytes(props.data.downloaded_bytes)} of{" "}
|
||||
{prettyBytes(props.data.size)}{" "}
|
||||
</span>
|
||||
@@ -20,13 +20,12 @@ export const DownloadProgressTick = (props): ReactElement => {
|
||||
%
|
||||
</progress>
|
||||
</div>
|
||||
<div className="is-size-5">
|
||||
{prettyBytes(props.data.speed)} per second.
|
||||
</div>
|
||||
<div className="is-size-5">
|
||||
<div className="is-size-6 mt-1 mb-2">
|
||||
<p>{prettyBytes(props.data.speed)} per second.</p>
|
||||
Time left:
|
||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
||||
</div>
|
||||
|
||||
<div>{props.data.target}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,108 +1,140 @@
|
||||
import React, { useEffect, useContext, ReactElement } from "react";
|
||||
import {
|
||||
getBundlesForComic,
|
||||
} from "../../actions/airdcpp.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
import { isEmpty, isNil, map } from "lodash";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import ellipsize from "ellipsize";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
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 {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
TORRENT_JOB_SERVICE_BASE_URI,
|
||||
SOCKET_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface IDownloadsPanelProps {
|
||||
data: any;
|
||||
comicObjectId: string;
|
||||
key: number;
|
||||
}
|
||||
|
||||
export const DownloadsPanel = (
|
||||
props: IDownloadsPanelProps,
|
||||
): ReactElement | null => {
|
||||
const bundles = useSelector((state: RootState) => {
|
||||
return state.airdcpp.bundles;
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||
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
|
||||
const userSettings = useSelector((state: RootState) => state.settings.data);
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
/**
|
||||
* Query to fetch AirDC++ download bundles for a given comic resource Id
|
||||
* @param {string} {comicObjectId} - A mongo id that identifies a comic document
|
||||
*/
|
||||
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",
|
||||
});
|
||||
|
||||
const {
|
||||
airDCPPState: { socket, settings },
|
||||
} = airDCPPConfiguration;
|
||||
// Call the scheduled job for fetching torrent data
|
||||
// triggered by the active tab been set to "torrents"
|
||||
const { data: torrentData } = useQuery({
|
||||
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>
|
||||
|
||||
const dispatch = useDispatch();
|
||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
||||
useEffect(() => {
|
||||
try {
|
||||
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]);
|
||||
<select id="Tab" className="w-full rounded-md border-gray-200">
|
||||
<option>DC++ Downloads</option>
|
||||
<option>Torrents</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
const Bundles = (props) => {
|
||||
return !isEmpty(props.data) ? (
|
||||
<div className="column is-full">
|
||||
<table className="table is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Download Time</th>
|
||||
<th>Bundle ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(props.data, (bundle) => (
|
||||
<tr key={bundle.id}>
|
||||
<td>
|
||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||
<span className="is-size-7">{bundle.target}</span>
|
||||
</td>
|
||||
<td>{prettyBytes(bundle.size)}</td>
|
||||
<td>
|
||||
{dayjs
|
||||
.unix(bundle.time_finished)
|
||||
.format("h:mm on ddd, D MMM, YYYY")}
|
||||
</td>
|
||||
<td>
|
||||
<span className="tag is-warning">{bundle.id}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
>
|
||||
DC++ Downloads
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "torrents"
|
||||
? "bg-slate-200 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
>
|
||||
Torrents
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="column is-full"> {"No Downloads Found"} </div>
|
||||
);
|
||||
};
|
||||
|
||||
return !isNil(props.data) ? (
|
||||
<>
|
||||
<div className="columns is-multiline">
|
||||
{!isEmpty(socket) ? (
|
||||
<Bundles data={bundles} />
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
{activeTab === "torrents" ? (
|
||||
<TorrentDownloads data={torrentDetails} />
|
||||
) : null}
|
||||
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
|
||||
<AirDCPPBundles data={bundles.data} />
|
||||
) : (
|
||||
"nutin"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsPanel;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import arrayMutators from "final-form-arrays";
|
||||
import { FieldArray } from "react-final-form-arrays";
|
||||
@@ -9,7 +8,9 @@ import TextareaAutosize from "react-textarea-autosize";
|
||||
export const EditMetadataPanel = (props): ReactElement => {
|
||||
const validate = async () => {};
|
||||
const onSubmit = async () => {};
|
||||
|
||||
|
||||
const { data } = props;
|
||||
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<AsyncSelectPaginate
|
||||
@@ -28,10 +29,9 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
const rawFileDetails = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
// const rawFileDetails = useSelector(
|
||||
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||
// );
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,90 +58,59 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
<label className="label">Issue Details</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<Field
|
||||
name="issue_name"
|
||||
component="input"
|
||||
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>
|
||||
<Field
|
||||
name="issue_name"
|
||||
component="input"
|
||||
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"
|
||||
initialValue={data.name}
|
||||
placeholder={"Issue Name"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Issue Number and year */}
|
||||
<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="issue_number"
|
||||
component="input"
|
||||
className="input"
|
||||
placeholder="Issue Number"
|
||||
/>
|
||||
<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>
|
||||
<div className="mt-4 flex flex-row gap-2">
|
||||
<div>
|
||||
<div className="text-sm">Issue Number</div>
|
||||
<Field
|
||||
name="issue_number"
|
||||
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="Issue Number"
|
||||
/>
|
||||
<p className="text-xs">Do not enter the first zero</p>
|
||||
</div>
|
||||
<div>
|
||||
{/* year */}
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<Field
|
||||
name="issue_year"
|
||||
component="input"
|
||||
className="input"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm">Issue Year</div>
|
||||
<Field
|
||||
name="issue_year"
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Description</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<Field
|
||||
name={"description"}
|
||||
className="textarea"
|
||||
component={TextareaAutosizeAdapter}
|
||||
placeholder={"Description"}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label className="text-sm">Description</label>
|
||||
<Field
|
||||
name={"description"}
|
||||
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"
|
||||
component={TextareaAutosizeAdapter}
|
||||
placeholder={"Description"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
|
||||
137
src/client/components/ComicDetail/MatchResult.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
@@ -1,96 +1,126 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isUndefined } from "lodash";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
export const RawFileDetails = (props): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata } = props.data;
|
||||
interface RawFileDetailsProps {
|
||||
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 (
|
||||
<>
|
||||
<div className="comic-detail raw-file-details">
|
||||
<dl>
|
||||
<dt>Raw File Details</dt>
|
||||
<dd className="is-size-7">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
</dd>
|
||||
<dd>
|
||||
<div className="field is-grouped mt-2">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Size</span>
|
||||
<span className="tag is-info is-light">
|
||||
<div className="max-w-2xl ml-5">
|
||||
<div className="px-4 sm:px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xl">{rawFileDetails.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="content comic-detail raw-file-details mt-3">
|
||||
<dl>
|
||||
{/* inferred metadata */}
|
||||
<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 className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(created_at), "h aaaa")}
|
||||
</dd>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Actions
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
import { useSelector } from "react-redux";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const TabControls = (props): ReactElement => {
|
||||
const comicBookDetailData = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const { filteredTabs } = props;
|
||||
|
||||
const { filteredTabs, downloadCount } = props;
|
||||
const [active, setActive] = useState(filteredTabs[0].id);
|
||||
useEffect(() => {
|
||||
setActive(filteredTabs[0].id);
|
||||
}, [comicBookDetailData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
{filteredTabs.map(({ id, name, icon }) => (
|
||||
<li
|
||||
key={id}
|
||||
className={id === active ? "is-active" : ""}
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<a>
|
||||
{id === 5 &&
|
||||
!isNil(comicBookDetailData.acquisition.directconnect) ? (
|
||||
<span className="download-icon-labels">
|
||||
<i className="fa-solid fa-download"></i>
|
||||
<span className="tag downloads-count is-info is-light">
|
||||
{comicBookDetailData.acquisition.directconnect.downloads.length}
|
||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
{filteredTabs.map(({ id, name, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<>
|
||||
{id === 6 && !isNil(downloadCount) ? (
|
||||
<span className="inline-flex flex-row">
|
||||
{/* download count */}
|
||||
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="icon is-small">{icon}</span>
|
||||
)}
|
||||
{name}
|
||||
) : (
|
||||
<span className="w-5 h-5">{icon}</span>
|
||||
)}
|
||||
{name}
|
||||
</>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
|
||||
@@ -1,36 +1,134 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { DnD } from "../../DnD";
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { DnD } from "../../shared/Draggable/DnD";
|
||||
import { isEmpty } from "lodash";
|
||||
import Sticky from "react-stickynode";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import { extractComicArchive } from "../../../actions/fileops.actions";
|
||||
import { analyzeImage } from "../../../actions/fileops.actions";
|
||||
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 => {
|
||||
const { data } = props;
|
||||
const isComicBookExtractionInProgress = useSelector(
|
||||
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
|
||||
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
const extractedComicBookArchive = useSelector(
|
||||
(state: RootState) => state.fileOps.extractedComicBookArchive,
|
||||
);
|
||||
|
||||
const imageAnalysisResult = useSelector((state: RootState) => {
|
||||
return state.fileOps.imageAnalysisResults;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const unpackComicArchive = useCallback(() => {
|
||||
dispatch(extractComicArchive(data.rawFileDetails.filePath));
|
||||
}, []);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
// current image
|
||||
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
|
||||
const contentForSlidingPanel = {
|
||||
@@ -38,13 +136,13 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
content: () => {
|
||||
return (
|
||||
<div>
|
||||
<pre className="is-size-7">{currentImage}</pre>
|
||||
<pre className="text-sm">{currentImage}</pre>
|
||||
{!isEmpty(imageAnalysisResult) ? (
|
||||
<pre className="is-size-7 p-2 mt-3">
|
||||
<pre className="p-2 mt-3">
|
||||
<Canvas data={imageAnalysisResult} />
|
||||
</pre>
|
||||
) : null}
|
||||
<pre className="is-size-7 mt-3">
|
||||
<pre className="font-hasklig mt-3 text-sm">
|
||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -56,54 +154,81 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
// sliding panel handlers
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||
setSlidingPanelContentId("imageAnalysis");
|
||||
dispatch(analyzeImage(imageFilePath));
|
||||
analyzeImage(imageFilePath);
|
||||
setCurrentImage(imageFilePath);
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div key={2}>
|
||||
<button
|
||||
className={
|
||||
isComicBookExtractionInProgress
|
||||
? "button is-loading is-warning"
|
||||
: "button is-warning"
|
||||
}
|
||||
onClick={unpackComicArchive}
|
||||
<article
|
||||
role="alert"
|
||||
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"
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fa-solid fa-box-open"></i>
|
||||
</span>
|
||||
<span>Unpack comic archive</span>
|
||||
</button>
|
||||
<div className="columns">
|
||||
<div className="mt-5">
|
||||
{!isEmpty(extractedComicBookArchive) ? (
|
||||
<div>
|
||||
<p>You can perform several operations on your comic book archive.</p>
|
||||
<p>
|
||||
Uncompressing, re-organizing the individual pages, then
|
||||
re-compressing to a different format, for example.
|
||||
</p>
|
||||
<p>You can also analyze color histograms of pages.</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="mt-5">
|
||||
{data.rawFileDetails.archive?.uncompressed &&
|
||||
!isEmpty(uncompressedArchive) ? (
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
This issue is already uncompressed at:
|
||||
<p>
|
||||
<code className="font-hasklig text-sm">
|
||||
{data.rawFileDetails.archive.expandedPath}
|
||||
</code>
|
||||
<div className="">It has {uncompressedArchive?.length} pages</div>
|
||||
</p>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-row gap-2 mt-4">
|
||||
{isEmpty(uncompressedArchive) ? (
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<span className="text-md">Unpack Comic Archive</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!isEmpty(uncompressedArchive) ? (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<span className="text-md">Convert to .cbz</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mt-10">
|
||||
{!isEmpty(uncompressedArchive) ? (
|
||||
<DnD
|
||||
data={extractedComicBookArchive}
|
||||
data={uncompressedArchive}
|
||||
onClickHandler={openImageAnalysisPanel}
|
||||
/>
|
||||
) : null}
|
||||
</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>
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
|
||||
@@ -4,51 +4,60 @@ import React, { ReactElement } from "react";
|
||||
export const ComicInfoXML = (data): ReactElement => {
|
||||
const { json } = data;
|
||||
return (
|
||||
<div className="comicInfo-metadata">
|
||||
<dl className="has-text-size-7">
|
||||
<dd className="has-text-weight-medium">{json.series[0]}</dd>
|
||||
<dd className="mt-2 mb-2">
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Pages</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{json.publisher[0]}
|
||||
<div className="flex md:w-4/5 lg:w-78">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||
<dt>
|
||||
<p className="text-lg">{json.series[0]}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
published by{" "}
|
||||
<span className="underline">
|
||||
{json.publisher[0]}
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
</dd>
|
||||
<span className="inline-flex flex-row gap-2">
|
||||
{/* Issue number */}
|
||||
{!isUndefined(json.number) && (
|
||||
<dd className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{parseInt(json.number[0], 10)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Issue #</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
<dd className="my-2">
|
||||
{/* Genre */}
|
||||
<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--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Pages</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{json.pagecount[0]}
|
||||
</span>
|
||||
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</div>
|
||||
{!isUndefined(json.genre) && (
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Genre</span>
|
||||
<span className="tag is-success is-light">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</dd>
|
||||
</span>
|
||||
|
||||
<dd className="my-1">
|
||||
{/* Summary */}
|
||||
{!isUndefined(json.summary) && (
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{json.summary[0]}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dd>
|
||||
<span className="is-size-7">{json.notes[0]}</span>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import ComicVineDetails from "../ComicVineDetails";
|
||||
import { convert } from "html-to-text";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
export const VolumeInformation = (props): ReactElement => {
|
||||
const { data } = props;
|
||||
const createDescriptionMarkup = (html) => {
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={1}>
|
||||
<div className="columns is-multiline">
|
||||
<ComicVineDetails
|
||||
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>
|
||||
<ComicVineDetails
|
||||
data={data.sourcedMetadata.comicvine}
|
||||
updatedAt={data.updatedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
77
src/client/components/ComicDetail/TorrentDownloads.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import dayjs from "dayjs";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
export const TorrentDownloads = (props) => {
|
||||
const { data } = props;
|
||||
console.log(Object.values(data));
|
||||
return (
|
||||
<>
|
||||
{data.map(({ torrent }) => {
|
||||
return (
|
||||
<dl className="mt-5 dark:text-slate-200 text-slate-600">
|
||||
<dt className="text-lg">{torrent.name}</dt>
|
||||
<p className="text-sm">{torrent.hash}</p>
|
||||
<p className="text-sm">
|
||||
Added on {dayjs.unix(torrent.added_on).format("ddd, D MMM, YYYY")}
|
||||
</p>
|
||||
|
||||
<p className="flex gap-2 mt-1">
|
||||
{torrent.progress > 0 ? (
|
||||
<>
|
||||
<progress
|
||||
className="w-80 mt-2 [&::-webkit-progress-bar]:rounded-lg [&::-webkit-progress-value]:rounded-lg [&::-webkit-progress-bar]:bg-slate-300 [&::-webkit-progress-value]:bg-green-400 [&::-moz-progress-bar]:bg-green-400 h-2"
|
||||
value={Math.floor(torrent.progress * 100).toString()}
|
||||
max="100"
|
||||
></progress>
|
||||
|
||||
<span>{Math.floor(torrent.progress * 100)}%</span>
|
||||
|
||||
{/* downloaded/left */}
|
||||
<p className="inline-flex items-center bg-slate-200 text-green-800 dark:text-green-900 text-xs font-medium px-2.5 py-1 rounded-md dark:bg-slate-400">
|
||||
<span className="pr-1">
|
||||
<i className="icon-[solar--arrow-to-down-left-outline] h-4 w-4"></i>
|
||||
</span>
|
||||
<span className="text-md">
|
||||
{prettyBytes(torrent.downloaded)}
|
||||
</span>
|
||||
{/* uploaded */}
|
||||
<span className="pr-1 text-orange-800 dark:text-orange-900 ml-2">
|
||||
<i className="icon-[solar--arrow-to-top-left-outline] h-4 w-4"></i>
|
||||
</span>
|
||||
<span className="text-md text-orange-800 dark:text-orange-900">
|
||||
{prettyBytes(torrent.uploaded)}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2">
|
||||
{/* Peers */}
|
||||
<span className="inline-flex items-center bg-slate-200 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1">
|
||||
<i className="icon-[solar--station-minimalistic-line-duotone] h-5 w-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900">
|
||||
{torrent.trackers_count}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Size */}
|
||||
<span className="inline-flex items-center bg-slate-200 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] h-4 w-4"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900">
|
||||
{prettyBytes(torrent.total_size)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TorrentDownloads;
|
||||
202
src/client/components/ComicDetail/TorrentSearchPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
@@ -1,126 +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";
|
||||
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;
|
||||
@@ -1,96 +1,81 @@
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { ReactElement } from "react";
|
||||
import ZeroState from "./ZeroState";
|
||||
import { RecentlyImported } from "./RecentlyImported";
|
||||
import { WantedComicsList } from "./WantedComicsList";
|
||||
import { VolumeGroups } from "./VolumeGroups";
|
||||
import { LibraryStatistics } from "./LibraryStatistics";
|
||||
import { PullList } from "./PullList";
|
||||
import {
|
||||
fetchVolumeGroups,
|
||||
getComicBooks,
|
||||
} from "../../actions/fileops.actions";
|
||||
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
|
||||
export const Dashboard = (): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(fetchVolumeGroups());
|
||||
dispatch(
|
||||
getComicBooks({
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
const { data: recentComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: false },
|
||||
},
|
||||
comicStatus: "recent",
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": false },
|
||||
comicStatus: "recent",
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
getComicBooks({
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
queryKey: ["recentComics"],
|
||||
});
|
||||
// Wanted Comics
|
||||
const { data: wantedComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: true, $ne: null },
|
||||
},
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": true },
|
||||
comicStatus: "wanted",
|
||||
}),
|
||||
);
|
||||
dispatch(getLibraryStatistics());
|
||||
}, []);
|
||||
queryKey: ["wantedComics"],
|
||||
});
|
||||
const { data: volumeGroups } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["volumeGroups"],
|
||||
});
|
||||
|
||||
const recentComics = useSelector(
|
||||
(state: RootState) => state.fileOps.recentComics,
|
||||
);
|
||||
const wantedComics = useSelector(
|
||||
(state: RootState) => state.fileOps.wantedComics,
|
||||
);
|
||||
const volumeGroups = useSelector(
|
||||
(state: RootState) => state.fileOps.comicVolumeGroups,
|
||||
);
|
||||
const { data: statistics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["libraryStatistics"],
|
||||
});
|
||||
|
||||
const libraryStatistics = useSelector(
|
||||
(state: RootState) => state.comicInfo.libraryStatistics,
|
||||
);
|
||||
return (
|
||||
<div className="container">
|
||||
<section className="section">
|
||||
<h1 className="title">Dashboard</h1>
|
||||
|
||||
{!isEmpty(recentComics) && !isEmpty(recentComics.docs) ? (
|
||||
<>
|
||||
{/* Pull List */}
|
||||
<PullList issues={recentComics} />
|
||||
|
||||
{/* Stats */}
|
||||
{!isEmpty(libraryStatistics) && (
|
||||
<LibraryStatistics stats={libraryStatistics} />
|
||||
)}
|
||||
{/* Wanted comics */}
|
||||
{!isEmpty(wantedComics) && (
|
||||
<WantedComicsList comics={wantedComics} />
|
||||
)}
|
||||
{/* Recent imports */}
|
||||
{!isEmpty(recentComics) && (
|
||||
<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 className="container mx-auto max-w-full">
|
||||
<PullList />
|
||||
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||
{/* Wanted comics */}
|
||||
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||
{/* Library Statistics */}
|
||||
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||
{/* Volume groups */}
|
||||
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,113 +1,99 @@
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty, isUndefined, map } from "lodash";
|
||||
import Header from "../shared/Header";
|
||||
|
||||
export const LibraryStatistics = (
|
||||
props: ILibraryStatisticsProps,
|
||||
): ReactElement => {
|
||||
// const { stats } = props;
|
||||
const { stats } = props;
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<h4 className="title is-4">
|
||||
<i className="fa-solid fa-chart-simple"></i> Your Library In Numbers
|
||||
</h4>
|
||||
<p className="subtitle is-7">A brief snapshot of your library.</p>
|
||||
<div className="columns is-multiline">
|
||||
<div className="column is-narrow is-two-quarter">
|
||||
<dl className="box">
|
||||
<dd className="is-size-4">
|
||||
<span className="has-text-weight-bold">
|
||||
{props.stats.totalDocuments}
|
||||
</span>{" "}
|
||||
files
|
||||
<Header
|
||||
headerContent="Your Library In Numbers"
|
||||
subHeaderContent={
|
||||
<span className="text-md">A brief snapshot of your library.</span>
|
||||
}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-row gap-5">
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{props.stats.totalDocuments} files
|
||||
</dd>
|
||||
<dd className="is-size-4">
|
||||
Library size
|
||||
<span className="has-text-weight-bold">
|
||||
{" "}
|
||||
<dd>
|
||||
<span className="text-2xl text-green-600">
|
||||
{props.stats.comicDirectorySize &&
|
||||
prettyBytes(props.stats.comicDirectorySize)}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
{/* comicinfo and comicvine tagged issues */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].issues) && (
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold">
|
||||
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics[0].issues.length}
|
||||
</span>{" "}
|
||||
tagged with ComicVine
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold">
|
||||
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics[0].issuesWithComicInfoXML.length}
|
||||
</span>{" "}
|
||||
with
|
||||
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||
ComicInfo.xml
|
||||
with ComicInfo.xml
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 column is-one-quarter">
|
||||
<dl className="box">
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold"></span> Issues
|
||||
</dd>
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold">304</span> Volumes
|
||||
</dd>
|
||||
<dd className="is-size-6">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||
return (
|
||||
<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>
|
||||
<div className="">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
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"
|
||||
>
|
||||
{fileType.data.length} {fileType._id}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file types */}
|
||||
<div className="p-3 column is-two-fifths">
|
||||
{/* publisher with most issues */}
|
||||
<dl className="box">
|
||||
{/* file types */}
|
||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||
{/* publisher with most issues */}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(
|
||||
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||
) && (
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold">
|
||||
<>
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0]._id
|
||||
}
|
||||
</span>
|
||||
{" has the most issues "}
|
||||
<span className="has-text-weight-bold">
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0].count
|
||||
}
|
||||
</span>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dd className="is-size-6">
|
||||
<span className="has-text-weight-bold">304</span> Volumes
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,162 +1,165 @@
|
||||
import { isNil, map } from "lodash";
|
||||
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
|
||||
import Card from "../Carda";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { map } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import { importToDB } from "../../actions/fileops.actions";
|
||||
import ellipsize from "ellipsize";
|
||||
import Slider from "react-slick";
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import "keen-slider/keen-slider.min.css";
|
||||
import { useKeenSlider } from "keen-slider/react";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import DatePickerDialog from "../shared/DatePicker";
|
||||
import { format } from "date-fns";
|
||||
|
||||
type PullListProps = {
|
||||
issues: any;
|
||||
};
|
||||
|
||||
export const PullList = ({ issues }: PullListProps): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
getWeeklyPullList({
|
||||
startDate: "2022-11-15",
|
||||
pageSize: "15",
|
||||
currentPage: "1",
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
const addToLibrary = useCallback(
|
||||
(sourceName: string, locgMetadata) =>
|
||||
dispatch(importToDB(sourceName, { locg: locgMetadata })),
|
||||
[],
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
maxRPS: 1,
|
||||
});
|
||||
const cachedAxios = setupCache(axios);
|
||||
export const PullList = (): ReactElement => {
|
||||
// datepicker
|
||||
const date = new Date();
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
format(date, "M-dd-yyyy"),
|
||||
);
|
||||
/*
|
||||
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,
|
||||
},
|
||||
// keen slider
|
||||
const [sliderRef, instanceRef] = useKeenSlider(
|
||||
{
|
||||
loop: true,
|
||||
slides: {
|
||||
origin: "auto",
|
||||
number: 15,
|
||||
perView: 5,
|
||||
spacing: 15,
|
||||
},
|
||||
{
|
||||
breakpoint: 600,
|
||||
settings: {
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 2,
|
||||
initialSlide: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
breakpoint: 480,
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
},
|
||||
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) =>
|
||||
importToDB(sourceName, { locg: locgMetadata });
|
||||
|
||||
const next = () => {
|
||||
sliderRef.slickNext();
|
||||
// sliderRef.slickNext();
|
||||
};
|
||||
const previous = () => {
|
||||
sliderRef.slickPrev();
|
||||
// sliderRef.slickPrev();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
<h4 className="title is-4">
|
||||
<i className="fa-solid fa-splotch"></i> Discover
|
||||
</h4>
|
||||
<p className="subtitle is-7">
|
||||
Pull List aggregated for the week from League Of Comic Geeks
|
||||
</p>
|
||||
<div className="field is-grouped">
|
||||
<Header
|
||||
headerContent="Discover"
|
||||
subHeaderContent={
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://www.tfaw.com/comics/new-releases.html">
|
||||
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 */}
|
||||
<div className="control">
|
||||
<div className="select is-small">
|
||||
<select>
|
||||
<option>Select Week</option>
|
||||
<option>With options</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* See all pull list issues */}
|
||||
<div className="control">
|
||||
<Link to={"/pull-list/all/"}>
|
||||
<button className="button is-small">View all issues</button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="field has-addons">
|
||||
<div className="control">
|
||||
<button className="button is-rounded is-small" onClick={previous}>
|
||||
<i className="fa-solid fa-caret-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button className="button is-rounded is-small" onClick={next}>
|
||||
<i className="fa-solid fa-caret-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4 my-3">
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
render={({ handleSubmit }) => (
|
||||
<form>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* week selection for pull list */}
|
||||
<DatePickerDialog
|
||||
inputValue={inputValue}
|
||||
setter={setInputValue}
|
||||
/>
|
||||
{inputValue && (
|
||||
<div className="text-sm">
|
||||
Showing pull list for{" "}
|
||||
<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">
|
||||
{inputValue}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slider {...settings} ref={(c) => (sliderRef = c)}>
|
||||
{!isNil(pullList) &&
|
||||
pullList &&
|
||||
map(pullList, ({issue}, idx) => {
|
||||
|
||||
{isSuccess && !isLoading && (
|
||||
<div ref={sliderRef} className="keen-slider flex flex-row">
|
||||
{map(pullList?.data.result, (issue, idx) => {
|
||||
return (
|
||||
<Card
|
||||
key={idx}
|
||||
orientation={"vertical"}
|
||||
imageUrl={issue.cover}
|
||||
hasDetails
|
||||
title={ellipsize(issue.name, 18)}
|
||||
cardContainerStyle={{
|
||||
marginRight: 22,
|
||||
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
|
||||
}}
|
||||
>
|
||||
<div className="content">
|
||||
<div className="control">
|
||||
<span className="tag">{issue.publisher}</span>
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.coverImageUrl}
|
||||
hasDetails
|
||||
title={ellipsize(issue.name, 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
{issue.publicationDate}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => addToLibrary("locg", issue)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Slider>
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? <div>Loading...</div> : null}
|
||||
{isError ? (
|
||||
<div>An error occurred while retrieving the pull list.</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,40 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import { Link } from "react-router-dom";
|
||||
import ellipsize from "ellipsize";
|
||||
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import Masonry from "react-masonry-css";
|
||||
import {
|
||||
determineCoverFile,
|
||||
determineExternalMetadata,
|
||||
} from "../../shared/utils/metadata.utils";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
import Header from "../shared/Header";
|
||||
|
||||
type RecentlyImportedProps = {
|
||||
comicBookCovers: any;
|
||||
comics: any;
|
||||
};
|
||||
|
||||
export const RecentlyImported = ({
|
||||
comicBookCovers,
|
||||
}: RecentlyImportedProps): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 5,
|
||||
1100: 4,
|
||||
700: 2,
|
||||
600: 2,
|
||||
};
|
||||
|
||||
export const RecentlyImported = (
|
||||
comics: RecentlyImportedProps,
|
||||
): ReactElement => {
|
||||
console.log(comics);
|
||||
return (
|
||||
<>
|
||||
<div className="content mt-5">
|
||||
<h4 className="title is-4">
|
||||
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
|
||||
</h4>
|
||||
<p className="subtitle is-7">
|
||||
Recent Library activity such as imports, tagging, etc.
|
||||
</p>
|
||||
</div>
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="recent-comics-container"
|
||||
columnClassName="recent-comics-column"
|
||||
>
|
||||
{map(
|
||||
comicBookCovers.docs,
|
||||
<div>
|
||||
<Header
|
||||
headerContent="Recently Imported"
|
||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{comics?.comics.map(
|
||||
(
|
||||
{
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
acquisition: {
|
||||
source: { name },
|
||||
},
|
||||
inferredMetadata,
|
||||
wanted: { source } = {},
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
@@ -59,91 +44,88 @@ export const RecentlyImported = ({
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
|
||||
const isComicBookMetadataAvailable =
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||
source,
|
||||
{
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
},
|
||||
);
|
||||
const isComicVineMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={_id}>
|
||||
<Card
|
||||
orientation={"vertical"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : null}
|
||||
>
|
||||
<div className="content is-flex is-flex-direction-row">
|
||||
{/* Raw file presence */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="icon custom-icon is-small has-text-danger mr-2">
|
||||
<img src="/img/missing-file.svg" />
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
key={idx}
|
||||
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||
title={inferredMetadata.issue.name}
|
||||
hasDetails
|
||||
>
|
||||
<div>
|
||||
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline]"></i>
|
||||
</span>
|
||||
)}
|
||||
{/* ComicInfo.xml presence */}
|
||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||
<span className="icon custom-icon is-small has-text-danger">
|
||||
<img
|
||||
src="/img/comicinfoxml.svg"
|
||||
alt={"ComicInfo.xml file detected."}
|
||||
/>
|
||||
<span className="text-md text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
)}
|
||||
{/* ComicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<span className="icon custom-icon">
|
||||
<img
|
||||
src="/img/cvlogo.svg"
|
||||
alt={"ComicInfo.xml file detected."}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(
|
||||
detectIssueTypes(comicvine.volumeInformation.description),
|
||||
) ? (
|
||||
<span className="tag is-warning">
|
||||
{
|
||||
detectIssueTypes(
|
||||
comicvine.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
|
||||
<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 */}
|
||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||
<div mt-1>
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||
</div>
|
||||
)}
|
||||
{/* ComicVine metadata presence */}
|
||||
{isComicVineMetadataAvailable && (
|
||||
<span className="w-7 h-7">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{/* metadata card */}
|
||||
{!isNil(name) ? (
|
||||
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
|
||||
<dd className="is-size-9">
|
||||
<dl>
|
||||
<span className="icon custom-icon">
|
||||
<img src={`/img/${icon}`} />
|
||||
</span>
|
||||
</dl>
|
||||
<dl>
|
||||
<span className="small-tag">
|
||||
{ellipsize(issue, 15)}
|
||||
</span>
|
||||
</dl>
|
||||
</dd>
|
||||
</Card>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
{/* Raw file presence */}
|
||||
{isNil(rawFileDetails) && (
|
||||
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Masonry>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,15 +2,10 @@ import { map, unionBy } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Masonry from "react-masonry-css";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
|
||||
export const VolumeGroups = (props): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 5,
|
||||
1100: 4,
|
||||
700: 2,
|
||||
500: 1,
|
||||
};
|
||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||
const navigate = useNavigate();
|
||||
@@ -19,44 +14,48 @@ export const VolumeGroups = (props): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="volumes-container mt-4">
|
||||
<div className="content">
|
||||
<a className="mb-1" onClick={navigateToVolumes}>
|
||||
<span className="is-size-4 has-text-weight-semibold">
|
||||
<i className="fa-solid fa-layer-group"></i> Volumes
|
||||
</span>
|
||||
<span className="icon mt-1">
|
||||
<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"
|
||||
>
|
||||
<section>
|
||||
<Header
|
||||
headerContent="Volumes"
|
||||
subHeaderContent="Based on ComicVine Volume information"
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/volumes"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(deduplicatedGroups, (data) => {
|
||||
return (
|
||||
<div className="stack" key={data._id}>
|
||||
<img src={data.volumes.image.small_url} />
|
||||
<div className="content">
|
||||
<div className="stack-title is-size-8">
|
||||
<Link to={`/volume/details/${data._id}`}>
|
||||
{ellipsize(data.volumes.name, 18)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag is-primary is-light">Issues</span>
|
||||
<span className="tag">{data.volumes.count_of_issues}</span>
|
||||
<div className="max-w-sm mx-auto" key={data._id}>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
key={data._id}
|
||||
imageUrl={data.volumes.image.small_url}
|
||||
hasDetails
|
||||
>
|
||||
<div className="py-3">
|
||||
<div className="text-sm">
|
||||
<Link to={`/volume/details/${data._id}`}>
|
||||
{ellipsize(data.volumes.name, 48)}
|
||||
</Link>
|
||||
</div>
|
||||
{/* issue count */}
|
||||
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{data.volumes.count_of_issues} issues
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
||||
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
||||
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Masonry>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import Header from "../shared/Header";
|
||||
|
||||
type WantedComicsListProps = {
|
||||
comics: any;
|
||||
@@ -14,47 +14,26 @@ type WantedComicsListProps = {
|
||||
export const WantedComicsList = ({
|
||||
comics,
|
||||
}: WantedComicsListProps): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 5,
|
||||
1100: 4,
|
||||
700: 2,
|
||||
500: 1,
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateToWantedComics = (row) => {
|
||||
navigate(`/wanted/all`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="content mt-6">
|
||||
<a className="mb-1" onClick={navigateToWantedComics}>
|
||||
<span className="is-size-4 has-text-weight-semibold">
|
||||
<i className="fa-solid fa-asterisk"></i> Wanted Comics
|
||||
</span>
|
||||
<span className="icon mt-1">
|
||||
<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"
|
||||
>
|
||||
<Header
|
||||
headerContent="Wanted Comics"
|
||||
subHeaderContent="Comics marked as wanted from various sources"
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/wanted"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(
|
||||
comics,
|
||||
({
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
wanted,
|
||||
}) => {
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
@@ -62,53 +41,82 @@ export const WantedComicsList = ({
|
||||
locg,
|
||||
};
|
||||
|
||||
const { issueName, url } = determineCoverFile(
|
||||
consolidatedComicMetadata,
|
||||
);
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={_id}
|
||||
orientation={"vertical"}
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="content is-flex is-flex-direction-row">
|
||||
<div className="pb-1">
|
||||
<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 */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<span className="icon custom-icon">
|
||||
<img src="/img/cvlogo.svg" />
|
||||
</span>
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(locg) && (
|
||||
<span className="icon custom-icon">
|
||||
<img src="/img/locglogo.svg" />
|
||||
</span>
|
||||
<img
|
||||
src="/src/client/assets/img/locglogo.svg"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(
|
||||
detectIssueTypes(comicvine.volumeInformation.description),
|
||||
) ? (
|
||||
<span className="tag is-warning">
|
||||
{
|
||||
detectIssueTypes(
|
||||
comicvine.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Masonry>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ interface ZeroStateProps {
|
||||
}
|
||||
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
|
||||
return (
|
||||
<article className="message is-info">
|
||||
<div className="message-body">
|
||||
<article className="">
|
||||
<div className="">
|
||||
<p>{props.header}</p>
|
||||
{props.message}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { getTransfers } from "../../actions/airdcpp.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
|
||||
@@ -12,16 +9,18 @@ interface IDownloadsProps {
|
||||
}
|
||||
|
||||
export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
const {
|
||||
airDCPPState: { settings, socket },
|
||||
} = airDCPPConfiguration;
|
||||
const dispatch = useDispatch();
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
const airDCPPTransfers = useSelector(
|
||||
(state: RootState) => state.airdcpp.transfers,
|
||||
);
|
||||
const issueBundles = useSelector((state: RootState) => state.airdcpp.issue_bundles);
|
||||
// const airDCPPTransfers = useSelector(
|
||||
// (state: RootState) => state.airdcpp.transfers,
|
||||
// );
|
||||
// const issueBundles = useSelector(
|
||||
// (state: RootState) => state.airdcpp.issue_bundles,
|
||||
// );
|
||||
const [bundles, setBundles] = useState([]);
|
||||
// Make the call to get all transfers from AirDC++
|
||||
useEffect(() => {
|
||||
@@ -38,62 +37,86 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||
useEffect(() => {
|
||||
if (!isUndefined(issueBundles)) {
|
||||
const foo = issueBundles.data.map((bundle) => {
|
||||
const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
|
||||
const {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
acquisition: {
|
||||
directconnect: { downloads },
|
||||
},
|
||||
sourcedMetadata: { locg, comicvine },
|
||||
} = bundle;
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails, comicvine, locg,
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
locg,
|
||||
});
|
||||
return { ...bundle, issueName, url }
|
||||
|
||||
})
|
||||
return { ...bundle, issueName, url };
|
||||
});
|
||||
setBundles(foo);
|
||||
}
|
||||
}, [issueBundles]);
|
||||
|
||||
}, [issueBundles])
|
||||
|
||||
return !isNil(bundles) ?
|
||||
return !isNil(bundles) ? (
|
||||
<div className="container">
|
||||
<section className="section">
|
||||
<h1 className="title">Downloads</h1>
|
||||
<div className="columns">
|
||||
<div className="column is-half">
|
||||
{bundles.map(bundle => {
|
||||
{bundles.map((bundle, idx) => {
|
||||
console.log(bundle);
|
||||
return <>
|
||||
<MetadataPanel
|
||||
data={bundle}
|
||||
imageStyle={{ maxWidth: 80 }}
|
||||
titleStyle={{ fontSize: "0.8rem" }}
|
||||
tagsStyle={{ fontSize: "0.7rem" }}
|
||||
containerStyle={{
|
||||
maxWidth: 400,
|
||||
padding: 0,
|
||||
margin: "0 0 8px 0",
|
||||
}} />
|
||||
return (
|
||||
<div key={idx}>
|
||||
<MetadataPanel
|
||||
data={bundle}
|
||||
imageStyle={{ maxWidth: 80 }}
|
||||
titleStyle={{ fontSize: "0.8rem" }}
|
||||
tagsStyle={{ fontSize: "0.7rem" }}
|
||||
containerStyle={{
|
||||
maxWidth: 400,
|
||||
padding: 0,
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
/>
|
||||
|
||||
<table className="table is-size-7">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Bundle ID</th>
|
||||
</tr>
|
||||
|
||||
{bundle.acquisition.directconnect.downloads.map((bundle) => {
|
||||
return(<tr>
|
||||
<td>{bundle.name}</td>
|
||||
<td>{bundle.size}</td>
|
||||
<td>{bundle.type.str}</td>
|
||||
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
|
||||
</tr>)
|
||||
})}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Bundle ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bundle.acquisition.directconnect.downloads.map(
|
||||
(bundle, idx) => {
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td>{bundle.name}</td>
|
||||
<td>{bundle.size}</td>
|
||||
<td>{bundle.type.str}</td>
|
||||
<td>
|
||||
<span className="tag is-warning">
|
||||
{bundle.bundleId}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
|
||||
</>
|
||||
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div> : <div>asd</div>;
|
||||
</div>
|
||||
) : (
|
||||
<div>There are no downloads.</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Downloads;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { debounce, isEmpty, map } from "lodash";
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
@@ -31,7 +31,7 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
||||
from: 0,
|
||||
},
|
||||
type: "volumeName",
|
||||
trigger: "globalSearchBar"
|
||||
trigger: "globalSearchBar",
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -51,7 +51,6 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
||||
<i className="fa-solid fa-magnifying-glass"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isEmpty(searchResults) ? (
|
||||
<div
|
||||
className="columns box is-multiline"
|
||||
@@ -62,9 +61,10 @@ export const SearchBar = (data: ISearchBarProps): ReactElement => {
|
||||
margin: "60px 0 0 350px",
|
||||
}}
|
||||
>
|
||||
{map(searchResults, (result) => (
|
||||
{map(searchResults, (result, idx) => (
|
||||
<MetadataPanel
|
||||
data={result}
|
||||
key={idx}
|
||||
imageStyle={{ maxWidth: 70 }}
|
||||
titleStyle={{ fontSize: "0.8rem" }}
|
||||
tagsStyle={{ fontSize: "0.7rem" }}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
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;
|
||||
298
src/client/components/Import/Import.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
@@ -1,48 +1,81 @@
|
||||
import React, { useMemo, ReactElement, useCallback, useEffect } from "react";
|
||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
import SearchBar from "../Library/SearchBar";
|
||||
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
|
||||
* @example
|
||||
* <Library />
|
||||
*/
|
||||
export const Library = (): ReactElement => {
|
||||
const searchResults = useSelector(
|
||||
(state: RootState) => state.fileOps.libraryComics,
|
||||
);
|
||||
const searchError = useSelector(
|
||||
(state: RootState) => state.fileOps.librarySearchError,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: 0,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage"
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
// Default page state
|
||||
// offset: 0
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: offset,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 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 navigateToComicDetail = (row) => {
|
||||
navigate(`/comic/details/${row.original._id}`);
|
||||
@@ -50,102 +83,112 @@ export const Library = (): ReactElement => {
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
<div className="comicvine-metadata">
|
||||
<dl>
|
||||
<span className="tags has-addons is-size-7">
|
||||
<span className="tag">Series</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{ellipsize(value.data.series[0], 25)}
|
||||
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
|
||||
{/* Series Name */}
|
||||
<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="pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{ellipsize(value.data.series[0], 45)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex flex-row mt-2 gap-2">
|
||||
{/* Pages */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
</dl>
|
||||
<dl>
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control">
|
||||
<span className="tags has-addons is-size-7 mt-2">
|
||||
<span className="tag">Pages</span>
|
||||
<span className="tag is-info is-light has-text-weight-bold">
|
||||
{value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Issue number */}
|
||||
<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--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{!isNil(value.data.number) && (
|
||||
<span>{parseInt(value.data.number[0], 10)}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const WantedStatus = ({ value }) => {
|
||||
return !value ? <span className="tag is-info is-light">Wanted</span> : null;
|
||||
};
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
footer: 1,
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 400,
|
||||
accessorKey: "_source",
|
||||
cell: info => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Comic Metadata",
|
||||
footer: 1,
|
||||
columns: [
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 400,
|
||||
accessorKey: "_source",
|
||||
cell: (info) => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
align: "center",
|
||||
minWidth: 250,
|
||||
cell: info =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : (
|
||||
<span className="tag mt-5">No ComicInfo.xml</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Additional Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "Publisher",
|
||||
accessorKey:
|
||||
"_source.sourcedMetadata.comicvine.volumeInformation",
|
||||
cell: info => {
|
||||
return (
|
||||
!isNil(info.getValue()) && (
|
||||
<h6 className="is-size-7 has-text-weight-bold">
|
||||
{info.getValue().publisher.name}
|
||||
</h6>
|
||||
)
|
||||
);
|
||||
{
|
||||
header: "ComicInfo.xml",
|
||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||
cell: (info) =>
|
||||
!isEmpty(info.getValue()) ? (
|
||||
<ComicInfoXML data={info.getValue()} />
|
||||
) : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Something",
|
||||
accessorKey: "_source.acquisition.source.wanted",
|
||||
cell: info => {
|
||||
!isUndefined(info.getValue()) ?
|
||||
<WantedStatus value={info.getValue().toString()} /> : "Nothing";
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Additional Metadata",
|
||||
columns: [
|
||||
{
|
||||
header: "Date of Import",
|
||||
accessorKey: "_source.createdAt",
|
||||
cell: (info) => {
|
||||
return !isNil(info.getValue()) ? (
|
||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
|
||||
{format(parseISO(info.getValue()), "h aaaa")}
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
], []);
|
||||
{
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (info) => (
|
||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
DC++: {info.getValue().directconnect.downloads.length}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex w-fit items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
Torrent: {info.getValue().torrent.length}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the next x (pageSize) items
|
||||
@@ -153,26 +196,23 @@ export const Library = (): ReactElement => {
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
*
|
||||
*
|
||||
**/
|
||||
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {},
|
||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||
if (!isPlaceholderData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: pageSize,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(pageSize * pageIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the previous x (pageSize) items
|
||||
@@ -181,58 +221,95 @@ export const Library = (): ReactElement => {
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
**/
|
||||
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||
}
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: pageSize,
|
||||
from,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage"
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||
setSearchQuery({
|
||||
query: {},
|
||||
pagination: {
|
||||
size: 15,
|
||||
from,
|
||||
},
|
||||
type: "all",
|
||||
trigger: "libraryPage",
|
||||
});
|
||||
// setOffset(from);
|
||||
};
|
||||
|
||||
// ImportStatus.propTypes = {
|
||||
// value: PropTypes.bool.isRequired,
|
||||
// };
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<div className="section">
|
||||
<div className="header-area">
|
||||
<h1 className="title">Library</h1>
|
||||
</div>
|
||||
{!isUndefined(searchResults.hits) && (
|
||||
<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">
|
||||
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 className="library">
|
||||
<div>
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
columns={columns}
|
||||
sourceData={searchResults?.hits?.hits}
|
||||
sourceData={searchResults?.hits.hits}
|
||||
rowClickHandler={navigateToComicDetail}
|
||||
paginationHandlers={{
|
||||
nextPage,
|
||||
previousPage,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||
</T2Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-screen-xl mt-5">
|
||||
<article
|
||||
role="alert"
|
||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
No comics were found in the library, Elasticsearch reports no
|
||||
indices. Try importing a few comics into the library and come
|
||||
back.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<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">
|
||||
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
|
||||
{!isUndefined(searchResults?.data?.meta?.body) ? (
|
||||
<p>
|
||||
{JSON.stringify(
|
||||
searchResults?.data.meta.body.error.root_cause,
|
||||
null,
|
||||
4,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import { getComicBooks } from "../../actions/fileops.actions";
|
||||
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||
import Masonry from "react-masonry-css";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
@@ -73,7 +73,7 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
||||
<div className="content is-flex is-flex-direction-row">
|
||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||
<span className="icon cv-icon is-small">
|
||||
<img src="/dist/img/cvlogo.svg" />
|
||||
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||
</span>
|
||||
)}
|
||||
{isNil(rawFileDetails) && (
|
||||
|
||||
@@ -1,63 +1,47 @@
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
|
||||
export const SearchBar = (): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
const handleSubmit = useCallback((e) => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {
|
||||
volumeName: e.search,
|
||||
},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: 0,
|
||||
},
|
||||
type: "volumeName",
|
||||
trigger: "libraryPage",
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
export const SearchBar = (props): ReactElement => {
|
||||
const { searchHandler } = props;
|
||||
return (
|
||||
<div className="box">
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={{}}
|
||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field is-grouped">
|
||||
<div className="control search is-expanded">
|
||||
<Field name="search">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<input
|
||||
{...input}
|
||||
className="input main-search-bar is-medium"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button className="button is-medium" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<Form
|
||||
onSubmit={searchHandler}
|
||||
initialValues={{}}
|
||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="search">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<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>
|
||||
|
||||
<input
|
||||
{...input}
|
||||
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
|
||||
type="text"
|
||||
id="search"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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;
|
||||
@@ -1,203 +0,0 @@
|
||||
import React 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";
|
||||
|
||||
const Navbar: React.FunctionComponent = (props) => {
|
||||
const downloadProgressTick = useSelector(
|
||||
(state: RootState) => state.airdcpp.downloadProgressData,
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="navbar is-fixed-top">
|
||||
<div className="navbar-brand">
|
||||
<Link to="/" className="navbar-item">
|
||||
<img
|
||||
src="/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 download-progress-meter">
|
||||
<a className="navbar-item">
|
||||
<DownloadProgressTick data={downloadProgressTick} />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</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;
|
||||
@@ -1,25 +1,23 @@
|
||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const PullList = (): ReactElement => {
|
||||
const pullListComics = useSelector(
|
||||
(state: RootState) => state.comicInfo.pullList,
|
||||
);
|
||||
// const pullListComics = useSelector(
|
||||
// (state: RootState) => state.comicInfo.pullList,
|
||||
// );
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
getWeeklyPullList({
|
||||
startDate: "2022-11-15",
|
||||
pageSize: "15",
|
||||
currentPage: "1",
|
||||
}),
|
||||
);
|
||||
// dispatch(
|
||||
// getWeeklyPullList({
|
||||
// startDate: "2023-7-28",
|
||||
// pageSize: "15",
|
||||
// currentPage: "1",
|
||||
// }),
|
||||
// );
|
||||
}, []);
|
||||
const nextPageHandler = () => {};
|
||||
const previousPageHandler = () => {};
|
||||
@@ -109,15 +107,15 @@ export const PullList = (): ReactElement => {
|
||||
{!isNil(pullListComics) && (
|
||||
<div>
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={pullListComics}
|
||||
columns={columnData}
|
||||
totalPages={pullListComics.length}
|
||||
paginationHandlers={{
|
||||
nextPage: nextPageHandler,
|
||||
previousPage: previousPageHandler,
|
||||
}}
|
||||
/>
|
||||
<T2Table
|
||||
sourceData={pullListComics}
|
||||
columns={columnData}
|
||||
totalPages={pullListComics.length}
|
||||
paginationHandlers={{
|
||||
nextPage: nextPageHandler,
|
||||
previousPage: previousPageHandler,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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;
|
||||
468
src/client/components/Search/Search.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
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;
|
||||
25
src/client/components/ServiceStatuses/ServiceStatuses.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
|
||||
export const ProwlarrSettingsForm = (props) => {
|
||||
const { data } = useQuery({
|
||||
queryFn: async (): any => {
|
||||
return await axios({
|
||||
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: "localhost",
|
||||
port: "9696",
|
||||
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: ["prowlarrConnectionResult"],
|
||||
});
|
||||
console.log(data);
|
||||
const submitHandler = () => {};
|
||||
const initialData = {};
|
||||
return (
|
||||
<>
|
||||
Prowlarr Settings.
|
||||
<Form
|
||||
onSubmit={submitHandler}
|
||||
initialValues={initialData}
|
||||
render={({ handleSubmit }) => (
|
||||
<form>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
<p>Configure Prowlarr integration here.</p>
|
||||
<p>
|
||||
Note that you need a Prowlarr instance hosted and running to
|
||||
configure the integration.
|
||||
</p>
|
||||
<p>
|
||||
See{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="http://airdcpp.net/docs/installation/installation.html"
|
||||
>
|
||||
here
|
||||
</a>{" "}
|
||||
for Prowlarr installation instructions for various platforms.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProwlarrSettingsForm;
|
||||
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
154
src/client/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import { flushDb } from "../../actions/settings.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { ReactElement } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export const SystemSettingsForm = (): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
const isSettingsCallInProgress = useSelector(
|
||||
(state: RootState) => state.settings.inProgress,
|
||||
);
|
||||
const flushDatabase = useCallback(() => {
|
||||
dispatch(flushDb());
|
||||
}, []);
|
||||
const { mutate: flushDb, isLoading } = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/library/flushDb`,
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="is-clearfix">
|
||||
@@ -47,15 +48,11 @@ export const SystemSettingsForm = (): ReactElement => {
|
||||
</article>
|
||||
|
||||
<button
|
||||
className={
|
||||
isSettingsCallInProgress
|
||||
? "button is-danger is-loading"
|
||||
: "button is-danger"
|
||||
}
|
||||
onClick={flushDatabase}
|
||||
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"
|
||||
onClick={() => flushDb()}
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-eraser"></i>
|
||||
<span className="pt-1 px-1">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
|
||||
</span>
|
||||
<span>Flush DB & Temporary Folders</span>
|
||||
</button>
|
||||
@@ -2,7 +2,7 @@ import { isArray, map } from "lodash";
|
||||
import React, { useEffect, ReactElement } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions";
|
||||
import { Card } from "../Carda";
|
||||
import { Card } from "../shared/Carda";
|
||||
import ellipsize from "ellipsize";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { useEffect, ReactElement, useState, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { ReactElement, useState, useCallback } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
getComicBookDetailById,
|
||||
getIssuesForSeries,
|
||||
analyzeLibrary,
|
||||
} from "../../actions/comicinfo.actions";
|
||||
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { Card } from "../Carda";
|
||||
import { Card } from "../shared/Carda";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
|
||||
const VolumeDetails = (props): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 6,
|
||||
1100: 4,
|
||||
700: 3,
|
||||
500: 2,
|
||||
};
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [storyArcsData, setStoryArcsData] = useState([]);
|
||||
const [active, setActive] = useState(1);
|
||||
|
||||
// sliding panel init
|
||||
@@ -33,7 +28,9 @@ const VolumeDetails = (props): ReactElement => {
|
||||
content: () => {
|
||||
const ids = map(matches, partialRight(pick, "_id"));
|
||||
const matchIds = ids.map((id: any) => id._id);
|
||||
return <PotentialLibraryMatches matches={matchIds} />;
|
||||
{
|
||||
/* return <PotentialLibraryMatches matches={matchIds} />; */
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -45,68 +42,145 @@ const VolumeDetails = (props): ReactElement => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const analyzeIssues = useCallback((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 analyzeIssues = useCallback((issues) => {
|
||||
// dispatch(analyzeLibrary(issues));
|
||||
// }, []);
|
||||
//
|
||||
|
||||
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 = () => (
|
||||
<>
|
||||
{!isUndefined(issuesForVolume) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
|
||||
{!isUndefined(issuesForSeries) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
|
||||
Analyze Library
|
||||
</div>
|
||||
) : null}
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="issues-container"
|
||||
columnClassName="issues-column"
|
||||
>
|
||||
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
|
||||
? issuesForVolume.map((issue) => {
|
||||
return (
|
||||
<>
|
||||
{isSuccess &&
|
||||
issuesForSeries.data.map((issue) => {
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
key={issue.id}
|
||||
imageUrl={issue.image.thumb_url}
|
||||
orientation={"vertical"}
|
||||
hasDetails
|
||||
borderColorClass={
|
||||
!isEmpty(issue.matches) ? "green-border" : ""
|
||||
}
|
||||
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
|
||||
onClick={() =>
|
||||
openPotentialLibraryMatchesPanel(issue.matches)
|
||||
}
|
||||
>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
: "loading"}
|
||||
</Masonry>
|
||||
imageUrl={issue.image.small_url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
const Issues = () => (
|
||||
<>
|
||||
<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>
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -115,20 +189,44 @@ const VolumeDetails = (props): ReactElement => {
|
||||
{
|
||||
id: 1,
|
||||
name: "Issues in Volume",
|
||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||
content: <IssuesInVolume key={1} />,
|
||||
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
|
||||
content: <Issues />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: <i className="fa-regular fa-mask"></i>,
|
||||
icon: (
|
||||
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
name: "Characters",
|
||||
content: <div key={2}>asdasd</div>,
|
||||
content: <div key={2}>Characters</div>,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <i className="fa-solid fa-scroll"></i>,
|
||||
name: "Arcs",
|
||||
content: <div key={3}>asdasd</div>,
|
||||
icon: (
|
||||
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,21 +234,26 @@ const VolumeDetails = (props): ReactElement => {
|
||||
const MetadataTabGroup = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<li
|
||||
key={id}
|
||||
className={id === active ? "is-active" : ""}
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<a>
|
||||
<span className="icon is-small">{icon}</span>
|
||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4" aria-label="Tabs">
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<span className="pt-1">{icon}</span>
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{tabGroup.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
@@ -158,97 +261,103 @@ const VolumeDetails = (props): ReactElement => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
!isUndefined(comicBookDetails.sourcedMetadata) &&
|
||||
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
|
||||
) {
|
||||
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
|
||||
const { sourcedMetadata } = comicObject.data;
|
||||
return (
|
||||
<div className="container volume-details">
|
||||
<div className="section">
|
||||
{/* Title */}
|
||||
<h1 className="title">
|
||||
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</h1>
|
||||
<div className="columns is-multiline">
|
||||
{/* Volume cover */}
|
||||
<div className="column is-narrow">
|
||||
<>
|
||||
<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">
|
||||
Browse your collection of volumes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="container mx-auto mt-4">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
{/* Volume cover */}
|
||||
<Card
|
||||
imageUrl={
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.image.small_url
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
}
|
||||
cardContainerStyle={{ maxWidth: 275 }}
|
||||
orientation={"vertical"}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="column is-three-fifths">
|
||||
<div className="field is-grouped mt-2">
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.id
|
||||
}
|
||||
</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">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.publisher.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.description,
|
||||
<div className="field is-grouped">
|
||||
{/* Title */}
|
||||
<span className="text-2xl">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</span>
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{sourcedMetadata.comicvine.volumeInformation.id}
|
||||
</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">
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
sourcedMetadata.comicvine.volumeInformation.publisher
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
</div>
|
||||
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</div>
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { searchIssue } from "../../actions/fileops.actions";
|
||||
import Card from "../Carda";
|
||||
import Card from "../shared/Carda";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import ellipsize from "ellipsize";
|
||||
import { convert } from "html-to-text";
|
||||
import { isUndefined } from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
|
||||
export const Volumes = (props): ReactElement => {
|
||||
const volumes = useSelector((state: RootState) => state.fileOps.volumes);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
|
||||
const {
|
||||
data: volumes,
|
||||
isSuccess,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||
method: "POST",
|
||||
data: {
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: 0,
|
||||
@@ -24,106 +30,61 @@ export const Volumes = (props): ReactElement => {
|
||||
type: "volumes",
|
||||
trigger: "volumesPage",
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
}),
|
||||
queryKey: ["volumes"],
|
||||
});
|
||||
console.log(volumes);
|
||||
const columnData = useMemo(
|
||||
() => [
|
||||
(): any => [
|
||||
{
|
||||
header: "Volume Details",
|
||||
id: "volumeDetails",
|
||||
minWidth: 450,
|
||||
accessorKey: "_source",
|
||||
cell: (row) => {
|
||||
const foo = row.getValue();
|
||||
accessorFn: (row) => row,
|
||||
cell: (row): any => {
|
||||
const comicObject = row.getValue();
|
||||
const {
|
||||
_source: { sourcedMetadata },
|
||||
} = comicObject;
|
||||
console.log("jaggu", row.getValue());
|
||||
return (
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<div className="comic-detail issue-metadata">
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="columns mt-2">
|
||||
<div className="">
|
||||
<Card
|
||||
imageUrl={
|
||||
foo.sourcedMetadata.comicvine.volumeInformation
|
||||
.image.thumb_url
|
||||
}
|
||||
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 className="flex flex-row gap-3 mt-5">
|
||||
<Link to={`/volume/details/${comicObject._id}`}>
|
||||
<Card
|
||||
imageUrl={
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</Link>
|
||||
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3">
|
||||
<div className="text-xl mb-1 w-fit">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</div>
|
||||
<p>
|
||||
{ellipsize(
|
||||
convert(
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
),
|
||||
180,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Download Status",
|
||||
header: "Other Details",
|
||||
columns: [
|
||||
{
|
||||
header: "Files",
|
||||
header: "Downloads",
|
||||
accessorKey: "_source.acquisition.directconnect",
|
||||
align: "right",
|
||||
cell: (props) => {
|
||||
@@ -144,12 +105,34 @@ export const Volumes = (props): ReactElement => {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
id: "Air",
|
||||
header: "Publisher",
|
||||
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
||||
cell: (props): any => {
|
||||
const row = props.getValue();
|
||||
return <div className="mt-5 text-md">{row.publisher.name}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
id: "dcc",
|
||||
header: "Issue Count",
|
||||
accessorKey:
|
||||
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
|
||||
cell: (props): any => {
|
||||
const row = props.getValue();
|
||||
return (
|
||||
<div className="mt-5">
|
||||
{/* issue count */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-6 h-6"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-lg text-slate-500 dark:text-slate-900">
|
||||
{row}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -157,28 +140,45 @@ export const Volumes = (props): ReactElement => {
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<section className="container">
|
||||
<div className="section">
|
||||
<div className="header-area">
|
||||
<h1 className="title">Volumes</h1>
|
||||
</div>
|
||||
{!isUndefined(volumes.hits) && (
|
||||
<div>
|
||||
<section className="">
|
||||
<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">
|
||||
Browse your collection of volumes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess ? (
|
||||
<div>
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={volumes?.hits?.hits}
|
||||
totalPages={volumes.hits.hits.length}
|
||||
sourceData={volumes?.data.hits.hits}
|
||||
totalPages={volumes?.data.hits.hits.length}
|
||||
paginationHandlers={{
|
||||
nextPage: () => {},
|
||||
previousPage: () => {},
|
||||
}}
|
||||
rowClickHandler={() => {}}
|
||||
columns={columnData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<div>An error was encountered while retrieving volumes</div>
|
||||
) : null}
|
||||
{isLoading ? <>Loading...</> : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
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 T2Table from "../shared/T2Table";
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
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 => {
|
||||
const wantedComics = useSelector(
|
||||
(state: RootState) => state.fileOps.wantedComics,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
const {
|
||||
data: wantedComics,
|
||||
isSuccess,
|
||||
isFetched,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||
method: "POST",
|
||||
data: {
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
|
||||
pagination: {
|
||||
size: 25,
|
||||
from: 0,
|
||||
},
|
||||
type: "wanted",
|
||||
trigger: "wantedComicsPage"
|
||||
trigger: "wantedComicsPage",
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
}),
|
||||
queryKey: ["wantedComics"],
|
||||
enabled: true,
|
||||
});
|
||||
const columnData = [
|
||||
{
|
||||
header: "Comic Information",
|
||||
@@ -37,8 +40,12 @@ export const WantedComics = (props): ReactElement => {
|
||||
header: "Details",
|
||||
id: "comicDetails",
|
||||
minWidth: 350,
|
||||
accessorFn: data => data,
|
||||
cell: (value) => <MetadataPanel data={value.getValue()} />,
|
||||
accessorFn: (data) => data,
|
||||
cell: (value) => {
|
||||
console.log("ASDASd", value);
|
||||
const row = value.getValue()._source;
|
||||
return row && <MetadataPanel data={row} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -47,10 +54,12 @@ export const WantedComics = (props): ReactElement => {
|
||||
columns: [
|
||||
{
|
||||
header: "Files",
|
||||
accessorKey: "acquisition",
|
||||
align: "right",
|
||||
cell: props => {
|
||||
const { directconnect: { downloads } } = props.getValue();
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (props) => {
|
||||
const {
|
||||
directconnect: { downloads },
|
||||
} = props.getValue();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -60,9 +69,7 @@ export const WantedComics = (props): ReactElement => {
|
||||
}}
|
||||
>
|
||||
{downloads.length > 0 ? (
|
||||
<span className="tag is-warning">
|
||||
{downloads.length}
|
||||
</span>
|
||||
<span className="tag is-warning">{downloads.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -71,12 +78,18 @@ export const WantedComics = (props): ReactElement => {
|
||||
{
|
||||
header: "Download Details",
|
||||
id: "downloadDetails",
|
||||
accessorKey: "acquisition",
|
||||
cell: data => <ol>
|
||||
{data.getValue().directconnect.downloads.map(download => {
|
||||
return <li className="is-size-7">{download.name}</li>;
|
||||
})}
|
||||
</ol>
|
||||
accessorKey: "_source.acquisition",
|
||||
cell: (data) => (
|
||||
<ol>
|
||||
{data.getValue().directconnect.downloads.map((download, idx) => {
|
||||
return (
|
||||
<li className="is-size-7" key={idx}>
|
||||
{download.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
@@ -92,26 +105,25 @@ export const WantedComics = (props): ReactElement => {
|
||||
* @param {number} pageIndex
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
*
|
||||
*
|
||||
**/
|
||||
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: pageSize,
|
||||
from: pageSize * pageIndex + 1,
|
||||
},
|
||||
type: "wanted",
|
||||
trigger: "wantedComicsPage",
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
// dispatch(
|
||||
// searchIssue(
|
||||
// {
|
||||
// query: {},
|
||||
// },
|
||||
// {
|
||||
// pagination: {
|
||||
// size: pageSize,
|
||||
// from: pageSize * pageIndex + 1,
|
||||
// },
|
||||
// type: "wanted",
|
||||
// trigger: "wantedComicsPage",
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }, []);
|
||||
|
||||
/**
|
||||
* Pagination control that fetches the previous x (pageSize) items
|
||||
@@ -120,55 +132,71 @@ export const WantedComics = (props): ReactElement => {
|
||||
* @param {number} pageSize
|
||||
* @returns void
|
||||
**/
|
||||
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
let from = 0;
|
||||
if (pageIndex === 2) {
|
||||
from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||
} else {
|
||||
from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||
}
|
||||
dispatch(
|
||||
searchIssue(
|
||||
{
|
||||
query: {},
|
||||
},
|
||||
{
|
||||
pagination: {
|
||||
size: pageSize,
|
||||
from,
|
||||
},
|
||||
type: "wanted",
|
||||
trigger: "wantedComicsPage"
|
||||
},
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
||||
// let from = 0;
|
||||
// if (pageIndex === 2) {
|
||||
// from = (pageIndex - 1) * pageSize + 2 - 17;
|
||||
// } else {
|
||||
// from = (pageIndex - 1) * pageSize + 2 - 16;
|
||||
// }
|
||||
// dispatch(
|
||||
// searchIssue(
|
||||
// {
|
||||
// query: {},
|
||||
// },
|
||||
// {
|
||||
// pagination: {
|
||||
// size: pageSize,
|
||||
// from,
|
||||
// },
|
||||
// type: "wanted",
|
||||
// trigger: "wantedComicsPage",
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<div className="section">
|
||||
<div className="header-area">
|
||||
<h1 className="title">Wanted Comics</h1>
|
||||
</div>
|
||||
{!isEmpty(wantedComics) && (
|
||||
<div className="">
|
||||
<section className="">
|
||||
<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">
|
||||
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 className="library">
|
||||
<T2Table
|
||||
sourceData={wantedComics}
|
||||
totalPages={wantedComics.length}
|
||||
sourceData={wantedComics?.data.hits.hits}
|
||||
totalPages={wantedComics?.data.hits.hits.length}
|
||||
columns={columnData}
|
||||
paginationHandlers={{
|
||||
nextPage: nextPage,
|
||||
previousPage: previousPage,
|
||||
nextPage: () => {},
|
||||
previousPage: () => {},
|
||||
}}
|
||||
// rowClickHandler={navigateToComicDetail}
|
||||
// rowClickHandler={navigateToComicDetail}
|
||||
/>
|
||||
{/* pagination controls */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{isLoading ? <div>Loading...</div> : null}
|
||||
{isError ? (
|
||||
<div>An error occurred while retrieving the pull list.</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
export const Canvas = (data) => {
|
||||
const { colorHistogramData } = data.data;
|
||||
console.log(data);
|
||||
export const Canvas = ({ data }) => {
|
||||
const { colorHistogramData } = data;
|
||||
const width = 559;
|
||||
const height = 200;
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
@@ -10,7 +9,11 @@ export const Canvas = (data) => {
|
||||
const canvas = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const context = canvas.current.getContext("2d");
|
||||
const context = canvas.current?.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.scale(pixelRatio, pixelRatio);
|
||||
const guideHeight = 8;
|
||||
const startY = height - guideHeight;
|
||||
@@ -46,18 +49,24 @@ export const Canvas = (data) => {
|
||||
context.stroke();
|
||||
|
||||
// Guide
|
||||
context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
|
||||
context.strokeStyle = `rgb(${i}, ${i}, ${i})`;
|
||||
context.beginPath();
|
||||
context.moveTo(x, startY);
|
||||
context.lineTo(x, height);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Perform cleanup actions here
|
||||
};
|
||||
}, [colorHistogramData, pixelRatio]);
|
||||
|
||||
const dw = Math.floor(pixelRatio * width);
|
||||
const dh = Math.floor(pixelRatio * height);
|
||||
const style = { width, height };
|
||||
|
||||
return <canvas ref={canvas} width={dw} height={dh} style={style} />;
|
||||
};
|
||||
|
||||
|
||||
173
src/client/components/shared/Carda.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
158
src/client/components/shared/ConnectionForm/ConnectionForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
98
src/client/components/shared/DatePicker.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import { ClassNames, DayPicker } from "react-day-picker";
|
||||
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
|
||||
import styles from "react-day-picker/dist/style.module.css";
|
||||
|
||||
export const DatePickerDialog = (props) => {
|
||||
const { setter, apiAction } = props;
|
||||
const [selected, setSelected] = useState<Date>();
|
||||
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
||||
|
||||
const classNames: ClassNames = {
|
||||
...styles,
|
||||
head: "custom-head",
|
||||
};
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { x, y, reference, floating, strategy, refs, update } = useFloating({
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(10), flip()],
|
||||
strategy: "absolute",
|
||||
});
|
||||
|
||||
const closePopper = () => {
|
||||
setIsPopperOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setIsPopperOpen(true);
|
||||
if (refs.reference.current && refs.floating.current) {
|
||||
autoUpdate(refs.reference.current, refs.floating.current, update);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaySelect = (date) => {
|
||||
setSelected(date);
|
||||
if (date) {
|
||||
setter(format(date, "yyyy-MM-dd"));
|
||||
apiAction();
|
||||
closePopper();
|
||||
} else {
|
||||
setter("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={reference}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Pick a date"
|
||||
onClick={handleButtonClick}
|
||||
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"
|
||||
>
|
||||
Pick a date
|
||||
</button>
|
||||
</div>
|
||||
{isPopperOpen && (
|
||||
<FocusTrap
|
||||
active
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
allowOutsideClick: true,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: closePopper,
|
||||
fallbackFocus: buttonRef.current || undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={floating}
|
||||
style={{
|
||||
position: strategy,
|
||||
zIndex: "999",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
|
||||
}}
|
||||
className="bg-slate-400 dark:bg-slate-500"
|
||||
role="dialog"
|
||||
aria-label="DayPicker calendar"
|
||||
>
|
||||
<DayPicker
|
||||
initialFocus={isPopperOpen}
|
||||
mode="single"
|
||||
defaultMonth={selected}
|
||||
selected={selected}
|
||||
onSelect={handleDaySelect}
|
||||
classNames={classNames}
|
||||
/>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePickerDialog;
|
||||
@@ -64,7 +64,7 @@ export const DnD = (data) => {
|
||||
className="mt-2 mb-2"
|
||||
onClick={(e) => data.onClickHandler(url)}
|
||||
>
|
||||
<div className="box p-2 pl-3 control-palette">
|
||||
<div className="box p-2 control-palette">
|
||||
<span className="tag is-warning mr-2">{index}</span>
|
||||
<span className="icon is-small mr-2">
|
||||
<i className="fa-solid fa-vial"></i>
|
||||
@@ -8,7 +8,6 @@ export function Grid({ children, columns }) {
|
||||
gridTemplateColumns: `repeat(${columns}, 200px)`,
|
||||
columnGap: 1,
|
||||
gridGap: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||