Compare commits

..

22 Commits

Author SHA1 Message Date
9d95888628 Merge branch 'master' into import-queue-progress 2023-08-30 13:46:20 -04:00
284ccefc79 🔧 Added a console log for debugging purposes 2023-08-30 12:25:54 -04:00
874ba6b337 🔧 More finishing touches to Import UX 2023-08-29 23:57:47 -04:00
7efbbe0cfe 🧹 Cleaning up the tabulated job results 2023-08-29 00:10:22 -04:00
80b7113ed5 🔧 Wired up jobStatistics call 2023-08-28 23:57:01 -04:00
75777d32dd 🔢 Removed the useless LS_IMPORT event 2023-08-27 20:25:38 -04:00
ffa1b0cc09 🏗️ Scaffold for job results 2023-08-24 23:19:15 -04:00
084453b093 🪡 Fixed margin on the comicinfo.xml panel on the library page 2023-08-24 09:05:58 -05:00
9d5cca913e ⛑️ Removed useless deps 2023-08-23 21:53:54 -05:00
bab92fccc8 🔧 Fixed the airdcpp-apisocket version 2023-08-23 13:51:54 -05:00
b0282ccbb3 🔀 Bumped deps 2023-08-23 12:03:42 -05:00
a5c02d3fc6 🔧 Hardening the import UX 2023-08-23 11:48:13 -05:00
debd9a20bf 🐂 Queue controls 2023-08-22 22:07:33 -05:00
b4ef0b7817 🐂 Queue drain event reducer 2023-08-22 05:20:41 -04:00
0c2cbb6937 🐂 Queue pause/resume functionality 2023-08-21 17:55:26 -04:00
8c6890a044 📝 Added more details to import statuses 2023-08-18 23:37:35 -04:00
a2ae4ce79d 🔧 Refactoring the Import Page 2023-08-18 11:38:37 -04:00
31cd53b7a2 🧹 Minor change in socket.io connection code 2023-08-14 19:46:10 -04:00
11ef9e6e2a 🔧 Tooling for resumable socket.io sessions 2023-07-27 11:08:51 -07:00
Rishi Ghan
26fdb6d3a4 🐂 Support for session-tracking 2023-07-26 10:15:07 -07:00
a2f8d8b1e8 🐂 Support for showing import progress 2023-07-13 08:03:16 -07:00
ae9ec75983 🚥 Added service status panel scaffold 2023-06-20 11:32:16 -04:00
159 changed files with 16361 additions and 40101 deletions

1
.gitignore vendored
View File

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

View File

@@ -7,6 +7,7 @@ const config: StorybookConfig = {
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
"@storybook/addon-mdx-gfm"
],
framework: {
name: "@storybook/react-vite",

View File

@@ -1,36 +1,19 @@
# Use Node.js 22 as the base image
FROM node:22-alpine
FROM node:18.15.0-alpine
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
# Set the working directory inside the container
WORKDIR /threetwo
# Copy package.json and yarn.lock to leverage Docker cache
COPY package.json yarn.lock ./
COPY package.json ./
COPY yarn.lock ./
COPY nodemon.json ./
COPY jsdoc.json ./
# Install build dependencies necessary for native modules (for node-sass)
RUN apk --no-cache add \
g++ \
make \
python3 \
autoconf \
automake \
libtool \
nasm \
git
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
RUN yarn --ignore-engines
# Install node modules
RUN yarn install --ignore-engines
# Explicitly install sass
RUN yarn add -D sass
# Copy the rest of the application files into the container
COPY . .
# Expose the application port (default for Vite)
EXPOSE 5173
# Start the application with yarn
ENTRYPOINT ["yarn", "start"]
ENTRYPOINT [ "npm", "start" ]

View File

@@ -6,25 +6,14 @@ ThreeTwo! _aims to be_ a comic book curation app.
### Screenshots
#### Dashboard
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Dashboard.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Dashboard.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/Library.png)
#### Issue View
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/DC%2B%2B%20integration.png)
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/ComicDetail.jpg)
![](https://github.com/rishighan/threetwo-visual-updates/raw/67e56878eb0381c73c1dea746a45253d3dcaa184/update_december_2022/ComicVine%20Matching.png)
#### DC++ Search
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/DC%2B%2BSearching.jpg)
#### Import
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/Import.jpg)
#### Comic Vine Matching, Metadata Scraping
![](https://raw.githubusercontent.com/rishighan/threetwo/ef05dee6005f683f1e4547631217681def9ebe86/screenshots/CVMatching.jpg)
### 🦄 Early Development Support Channel
@@ -39,8 +28,7 @@ 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-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.
4. [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
@@ -55,18 +43,20 @@ For debugging and troubleshooting, you can run this app locally using these step
3. This will open `http://localhost:5173` in your default browser
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
## Troubleshooting
## Troubleshooting
### Docker
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)

View File

@@ -1 +0,0 @@
module.exports = 'test-file-stub';

View File

@@ -1,16 +0,0 @@
schema: http://localhost:3000/graphql
documents: 'src/client/graphql/**/*.graphql'
generates:
src/client/graphql/generated.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-query
config:
fetcher:
func: './fetcher#fetcher'
isReactHook: false
exposeFetcher: true
exposeQueryKeys: true
addInfiniteQuery: true
reactQueryVersion: 5

View File

@@ -1,14 +1,16 @@
<!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">
<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>
<script type="module" src="/src/client/index.tsx"></script>
</body>
</html>
</body>
</html>

View File

@@ -1,28 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)',
],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', {
tsconfig: {
jsx: 'react',
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
}],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
};

View File

@@ -1,25 +0,0 @@
require('@testing-library/jest-dom');
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;

View File

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

13
nodemon.json Normal file
View File

@@ -0,0 +1,13 @@
{
"ignore": [
"**/*.test.ts",
"**/*.spec.ts",
"node_modules",
"src/client"
],
"watch": [
"src/server"
],
"exec": "tsc -p tsconfig.server.json && node server/",
"ext": "ts"
}

21428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,147 +1,131 @@
{
"name": "threetwo",
"version": "0.1.0",
"description": "ThreeTwo! A good comic book curator.",
"version": "0.0.2",
"description": "ThreeTwo! A comic book curator.",
"main": "server/index.js",
"typings": "server/index.js",
"scripts": {
"build": "vite build",
"dev": "rimraf dist && yarn build && vite",
"start": "yarn build && vite",
"dev": "rimraf dist && npm run build && vite",
"start": "npm run build && vite",
"docs": "jsdoc -c jsdoc.json",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
"codegen:watch": "graphql-codegen --config codegen.yml --watch"
"build-storybook": "storybook build"
},
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.18",
"@floating-ui/react-dom": "^2.1.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@types/mime-types": "^3.0.1",
"@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.3.0",
"@redux-devtools/extension": "^3.2.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-table": "^8.9.3",
"@types/mime-types": "^2.1.0",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"airdcpp-apisocket": "^3.0.0-beta.14",
"axios": "^1.13.5",
"axios-cache-interceptor": "^1.11.4",
"axios-rate-limit": "^1.6.2",
"babel-plugin-styled-components": "^2.1.4",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"ellipsize": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"filename-parser": "^1.0.4",
"final-form": "^5.0.0",
"final-form-arrays": "^4.0.0",
"focus-trap-react": "^12.0.0",
"graphql": "^16.13.1",
"@vitejs/plugin-react": "^3.1.0",
"airdcpp-apisocket": "^2.5.0-beta.2",
"axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0",
"babel-polyfill": "^6.26.0",
"babel-preset-minify": "^0.5.2",
"better-docs": "^2.7.2",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.5.1",
"express": "^4.17.1",
"filename-parser": "^1.0.2",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
"history": "^5.3.0",
"html-to-text": "^9.0.5",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.2",
"immer": "^11.1.4",
"jsdoc": "^4.0.5",
"lodash": "^4.17.23",
"pretty-bytes": "^7.1.0",
"html-to-text": "^8.1.0",
"jsdoc": "^3.6.10",
"lodash": "^4.17.21",
"node-sass": "npm:sass",
"pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1",
"qs": "^6.15.0",
"react": "^19.2.4",
"react-collapsible": "^2.10.0",
"react-comic-viewer": "^0.5.1",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-fast-compare": "^3.2.2",
"react-final-form": "^7.0.0",
"react-final-form-arrays": "^4.0.0",
"react-i18next": "^16.5.4",
"react-loader-spinner": "^8.0.2",
"react-modal": "^3.16.3",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-select": "^5.10.2",
"react-select-async-paginate": "^0.7.11",
"react-sliding-pane": "^7.3.0",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^11.0.5",
"rxjs": "^7.8.2",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"qs": "^6.10.5",
"react": "^18.2.0",
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"react-redux": "^8.0.5",
"react-router": "^6.9.0",
"react-router-dom": "^6.9.0",
"react-select": "^5.3.2",
"react-select-async-paginate": "^0.7.2",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1",
"redux-first-history": "^5.1.1",
"redux-socket.io-middleware": "^1.0.4",
"redux-thunk": "^2.4.2",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^6.0.7",
"threetwo-ui-typings": "^1.0.14",
"vite": "^7.3.1",
"vite-plugin-html": "^3.2.2",
"websocket": "^1.0.35",
"zustand": "^5.0.11"
"vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.1.2",
"@graphql-codegen/typescript": "^5.0.8",
"@graphql-codegen/typescript-operations": "^5.0.8",
"@graphql-codegen/typescript-react-query": "^6.1.2",
"@iconify-json/solar": "^1.2.5",
"@iconify/json": "^2.2.443",
"@iconify/tailwind": "^1.2.0",
"@iconify/tailwind4": "^1.2.1",
"@iconify/utils": "^3.1.0",
"@storybook/addon-essentials": "^8.6.17",
"@storybook/addon-interactions": "^8.6.17",
"@storybook/addon-links": "^8.6.17",
"@storybook/addon-onboarding": "^8.6.17",
"@storybook/blocks": "^8.6.17",
"@storybook/react": "^8.6.17",
"@storybook/react-vite": "^8.6.17",
"@storybook/testing-library": "^0.2.2",
"@tailwindcss/postcss": "^4.2.1",
"@tanstack/eslint-plugin-query": "^5.91.4",
"@tanstack/react-query-devtools": "^5.91.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/ellipsize": "^0.1.3",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"autoprefixer": "^10.4.27",
"docdash": "^2.0.2",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^62.7.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-storybook": "^0.11.1",
"identity-obj-proxy": "^3.0.0",
"@storybook/addon-essentials": "^7.3.2",
"@storybook/addon-interactions": "^7.3.2",
"@storybook/addon-links": "^7.3.2",
"@storybook/addon-mdx-gfm": "^7.3.2",
"@storybook/addon-onboarding": "^1.0.8",
"@storybook/blocks": "^7.3.2",
"@storybook/react": "^7.3.2",
"@storybook/react-vite": "^7.3.2",
"@storybook/testing-library": "^0.2.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": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-redux": "^7.1.25",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"babel-plugin-styled-components": "^2.1.4",
"body-parser": "^1.19.0",
"bulma": "^0.9.4",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.13",
"express": "^4.17.1",
"install": "^0.13.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"prettier": "^3.8.1",
"react-refresh": "^0.18.0",
"rimraf": "^6.1.3",
"sass": "^1.97.3",
"storybook": "^8.6.17",
"tailwindcss": "^4.2.1",
"ts-jest": "^29.4.6",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^5.9.3",
"wait-on": "^9.0.4"
},
"resolutions": {
"jackspeak": "2.1.1"
"jest": "^29.6.3",
"nodemon": "^3.0.1",
"npm": "^8.11.0",
"prettier": "^2.2.1",
"react-refresh": "^0.14.0",
"rimraf": "^4.1.3",
"sass": "^1.66.1",
"storybook": "^7.3.2",
"tslint": "^6.1.3",
"typescript": "^5.1.6",
"vite": "^4.4.9"
}
}

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 KiB

View File

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

View File

@@ -43,7 +43,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
});
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
};
@@ -73,9 +73,10 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
break;
default:
break;
console.log("Could not complete request.");
}
} catch (error) {
console.log(error);
dispatch({
type: CV_API_GENERIC_FAILURE,
error,
@@ -98,6 +99,7 @@ export const getIssuesForSeries =
comicObjectID,
},
});
console.log(issues);
dispatch({
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
issues: issues.data.results,

View File

@@ -34,6 +34,7 @@ import {
LS_SET_QUEUE_STATUS,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { success } from "react-notification-system-redux";
import { isNil } from "lodash";
@@ -150,7 +151,7 @@ export const getComicBooks = (options) => async (dispatch) => {
});
break;
default:
break;
console.log("Unrecognized comic status.");
}
};
@@ -218,11 +219,12 @@ export const fetchVolumeGroups = () => async (dispatch) => {
data: response.data,
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
};
export const fetchComicVineMatches =
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
console.log(issueSearchQuery);
try {
dispatch({
type: CV_API_CALL_IN_PROGRESS,
@@ -271,7 +273,7 @@ export const fetchComicVineMatches =
});
});
} catch (error) {
// Error handling could be added here if needed
console.log(error);
}
dispatch({

View File

@@ -7,6 +7,8 @@ export const fetchMetronResource = async (options) => {
`${METRON_SERVICE_URI}/fetchResource`,
options,
);
console.log(metronResourceResults);
console.log("has more? ", !isNil(metronResourceResults.data.next));
const results = metronResourceResults.data.results.map((result) => {
return {
label: result.name || result.__str__,

View File

@@ -3,14 +3,25 @@ import {
SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} from "../reducers/settings.reducer";
} from "../constants/action-types";
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`,
@@ -56,22 +67,3 @@ export const flushDb = () => async (dispatch) => {
});
}
};
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
method: "POST",
data: hostInfo,
});
const qBittorrentClientInfo = await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
method: "GET",
});
dispatch({
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
data: qBittorrentClientInfo.data,
});
};
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,160 @@
import React, { ReactElement, useEffect } from "react";
import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2";
import { ToastContainer } from "react-toastify";
import "../assets/scss/App.css";
import { useStore } from "../store";
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 "../assets/scss/App.scss";
import {
AirDCPPSocketContextProvider,
AirDCPPSocketContext,
} from "../context/AirDCPPSocket";
import { SocketIOProvider } from "../context/SocketIOContext";
import socketIOConnectionInstance from "../shared/socket.io/instance";
import { isEmpty, isNil, isUndefined } from "lodash";
import {
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
LS_SINGLE_IMPORT,
} from "../constants/action-types";
import { useDispatch, useSelector } from "react-redux";
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
const AirDCPPSocketComponent = (): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
export const App = (): ReactElement => {
useEffect(() => {
useStore.getState().getSocket("/"); // Connect to the base namespace
const initializeAirDCPPEventListeners = async () => {
if (
!isUndefined(airDCPPConfiguration.airDCPPState) &&
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
) {
await airDCPPConfiguration.airDCPPState.socket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
// download tick listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
dispatch({
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
downloadProgressData,
});
},
);
// download complete listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
dispatch({
type: LS_SINGLE_IMPORT,
meta: { remote: true },
data: bundleData,
});
count += 1;
}
}
},
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle changes",
);
console.log(
"[AirDCPP]: Listener registered - listening to transfer completion",
);
}
};
initializeAirDCPPEventListeners();
}, [airDCPPConfiguration]);
return <></>;
};
export const App = (): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
// Check if there is a sessionId in localStorage
const sessionId = localStorage.getItem("sessionId");
if (!isNil(sessionId)) {
// Resume the session
dispatch({
type: "RESUME_SESSION",
meta: { remote: true },
session: { sessionId },
});
} else {
// Inititalize the session and persist the sessionId to localStorage
socketIOConnectionInstance.on("sessionInitialized", (sessionId) => {
localStorage.setItem("sessionId", sessionId);
});
}
}, []);
return (
<>
<Navbar2 />
<Outlet />
<ToastContainer stacked hideProgressBar />
</>
<SocketIOProvider socket={socketIOConnectionInstance}>
<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>
</SocketIOProvider>
);
};

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
import {
removeLeadingPeriod,
escapePoundSymbol,
} from "../shared/utils/formatting.utils";
import { isUndefined, isEmpty, isNil } from "lodash";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
import ellipsize from "ellipsize";
interface IProps {
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
mongoObjId?: number;
hasTitle: boolean;
title?: string;
isHorizontal: boolean;
}
interface IState {}
class Card extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
}
public drawCoverCard = (
metadata: IExtractedComicBookCoverFile,
): JSX.Element => {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
);
const filePath = escapePoundSymbol(encodedFilePath);
return (
<div>
<div className="card generic-card">
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
<div className="card-image">
<figure className="image">
<img src={filePath} alt="Placeholder image" />
</figure>
</div>
{this.props.hasTitle && (
<div className="card-content">
<ul>
<Link to={"/comic/details/" + this.props.mongoObjId}>
<li className="has-text-weight-semibold">
{ellipsize(metadata.name, 18)}
</li>
</Link>
</ul>
</div>
)}
</div>
</div>
</div>
);
};
public render() {
return (
<>
{!isUndefined(this.props.comicBookCoversMetadata) &&
!isEmpty(this.props.comicBookCoversMetadata) &&
this.drawCoverCard(this.props.comicBookCoversMetadata)}
</>
);
}
}
export default Card;

View File

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

View File

@@ -1,22 +1,21 @@
import React, {
useCallback,
useContext,
ReactElement,
useEffect,
useRef,
useState,
} from "react";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
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 { 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";
import type { Socket } from "socket.io-client";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
interface IAcquisitionPanelProps {
query: any;
@@ -28,331 +27,253 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const socketRef = useRef<Socket>();
const queryClient = useQueryClient();
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
const { comicObjectId } = props;
const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
useEffect(() => {
const socket = useStore.getState().getSocket("manual");
socketRef.current = socket;
// --- Handlers ---
const handleResultAdded = ({ result }: any) => {
setAirDCPPSearchResults((prev) =>
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
);
};
const handleResultUpdated = ({ result }: any) => {
setAirDCPPSearchResults((prev) => {
const idx = prev.findIndex((r) => r.id === result.id);
if (idx === -1) return prev;
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
const next = [...prev];
next[idx] = result;
return next;
});
};
const handleSearchInitiated = (data: any) => {
setAirDCPPSearchInstance(data.instance);
};
const handleSearchesSent = (data: any) => {
setAirDCPPSearchInfo(data.searchInfo);
};
// --- Subscribe once ---
socket.on("searchResultAdded", handleResultAdded);
socket.on("searchResultUpdated", handleResultUpdated);
socket.on("searchInitiated", handleSearchInitiated);
socket.on("searchesSent", handleSearchesSent);
return () => {
socket.off("searchResultAdded", handleResultAdded);
socket.off("searchResultUpdated", handleResultUpdated);
socket.off("searchInitiated", handleSearchInitiated);
socket.off("searchesSent", handleSearchesSent);
// if you want to fully close the socket:
// useStore.getState().disconnectSocket("/manual");
};
}, []);
const {
data: settings,
isLoading,
isError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
// 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 { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
// const settings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
const [dcppQuery, setDcppQuery] = useState({});
useEffect(() => {
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}
}, [airDCPPConfiguration]);
useEffect(() => {
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs?.data, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, [hubs, sanitizedIssueName]);
const search = async (searchData: any) => {
setAirDCPPSearchResults([]);
socketRef.current?.emit("call", "socket.search", {
query: searchData,
namespace: "/manual",
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
});
};
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
config: any,
): Promise<void> => {
socketRef.current?.emit(
"call",
"socket.download",
{
searchInstanceId,
resultId,
comicObjectId,
name,
size,
type,
config,
},
(data: any) => {
// Download initiated
},
);
};
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);
};
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],
);
// 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="mt-5 mb-3">
{!isEmpty(hubs?.data) ? (
<div className="comic-detail columns">
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
<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>
</div>
<input
{...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
<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>
<div className="column">
<button
type="submit"
className={
isAirDCPPSearchInProgress
? "button is-loading is-warning"
: "button"
}
>
<span className="icon is-small">
<img src="/src/client/assets/img/airdcpp_logo.svg" />
</span>
<span className="airdcpp-text">Search on AirDC++</span>
</button>
</div>
</div>
</div>
</form>
)}
/>
) : (
<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 &gt; AirDC++ &gt; Hubs</code>.
</article>
)}
</div>
{/* configured hub */}
{!isEmpty(hubs?.data) && (
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
<span className="pr-1 pt-1">
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
</span>
{hubs && hubs?.data[0].hub_url}
</span>
)}
{/* AirDC++ search instance details */}
{!isNil(airDCPPSearchInstance) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs?.data.map((value, idx: string) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
<div 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>
{/* AirDC++ search instance details */}
{!isNil(searchInfo) && !isNil(searchInstance) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
({ value }) => (
<span className="tag is-warning" key={value}>
{value}
</span>
),
)}
</div>
</dt>
<dt>
Query:{" "}
<span className="has-text-weight-semibold">
{searchInfo.query.pattern}
</span>
</dt>
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<dd>File type: {searchInfo.query.file_type}</dd>
</dl>
</div>
</div>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="">
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="overflow-x-auto max-w-full mt-6">
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
<div className="column">
<table className="table">
<thead>
<tr className="border-b border-gray-300 dark:border-slate-700">
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Name
</th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Type
</th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Slots
</th>
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Actions
</th>
<tr>
<th>Name</th>
<th>Type</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{map(
airDCPPSearchResults,
({ dupe, type, name, id, slots, users, size }, idx) => (
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
<tr
key={idx}
className={
!isNil(dupe)
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
: "border-b border-gray-200 dark:border-slate-700 text-sm"
!isNil(result.dupe) ? "dupe-search-result" : ""
}
>
{/* NAME */}
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
<td>
<p className="mb-2">
{type.id === "directory" && (
<i className="fas fa-folder mr-1"></i>
)}
{ellipsize(name, 45)}
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}{" "}
{ellipsize(result.name, 70)}
</p>
<dl>
<dd>
<div className="inline-flex flex-wrap gap-1">
{!isNil(dupe) && (
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
Dupe
</span>
)}
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
{users.user.nicks}
<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>
{users.user.flags.map((flag, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
>
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
{result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}>
{flag}
</span>
))}
@@ -360,77 +281,52 @@ export const AcquisitionPanel = (
</dd>
</dl>
</td>
{/* TYPE */}
<td className="px-2 py-3">
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
{type.str}
<td>
<span className="tag is-light is-info">
{result.type.id === "directory"
? "directory"
: result.type.str}
</span>
</td>
{/* SLOTS */}
<td className="px-2 py-3">
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
{slots.total} slots; {slots.free} free
</span>
<td>
<div className="tags has-addons">
<span className="tag is-success">
{result.slots.free} free
</span>
<span className="tag is-light">
{result.slots.total}
</span>
</div>
</td>
{/* ACTIONS */}
<td className="px-2 py-3">
<button
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
<td>
<a
onClick={() =>
download(
airDCPPSearchInstance.id,
id,
comicObjectId,
name,
size,
type,
{
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
downloadDCPPResult(
searchInstance.id,
result.id,
result.name,
result.size,
result.type,
)
}
>
Download
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
</button>
<i className="fas fa-file-download"></i>
</a>
</td>
</tr>
),
)}
);
})}
</tbody>
</table>
</div>
) : (
<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>
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
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 className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
or may not work at all. Searches from <code>ADCS</code> hubs are
more reliable than <code>NMDCS</code> ones.
</div>
</article>
</div>
@@ -440,4 +336,4 @@ export const AcquisitionPanel = (
);
};
export default AcquisitionPanel;
export default AcquisitionPanel;

View File

@@ -1,26 +1,87 @@
import React, { ReactElement } from "react";
import Select from "react-select";
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";
export const Menu = (props): ReactElement => {
const {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
} = props.configuration;
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;
}
};
return (
<Select
className="basic-single"
classNamePrefix="select"
components={{ Placeholder }}
placeholder={
<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>
<i className="fa-solid fa-list"></i> Actions
</span>
}
styles={customStyles}
name="actions"
isSearchable={false}
options={filteredActionOptions}

View File

@@ -1,62 +0,0 @@
import React from "react";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { map } from "lodash";
import { DownloadProgressTick } from "./DownloadProgressTick";
export const AirDCPPBundles = (props) => {
return (
<div className="overflow-x-auto w-fit mt-6">
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
<thead>
<tr className="border-b border-gray-300 dark:border-slate-700">
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Filename
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Download Status
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
Bundle ID
</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle, index) => (
<tr
key={bundle.id}
className={
Number(index) !== props.data.length - 1
? "border-b border-gray-200 dark:border-slate-700"
: ""
}
>
<td className="px-3 py-2 align-top">
<h5 className="font-medium text-gray-800 dark:text-slate-200">
{ellipsize(bundle.name, 58)}
</h5>
<p className="text-xs text-gray-500 dark:text-slate-400">
{ellipsize(bundle.target, 88)}
</p>
</td>
<td className="px-3 py-2 align-top">
{prettyBytes(bundle.size)}
</td>
<td className="px-3 py-2 align-top">
<DownloadProgressTick bundleId={bundle.id} />
</td>
<td className="px-3 py-2 align-top">
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium">
{bundle.id}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -1,17 +1,11 @@
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);
interface AsyncSelectPaginateProps {
metronResource: string;
placeholder?: string;
value?: object;
onChange?(...args: unknown[]): unknown;
}
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
export const AsyncSelectPaginate = (props): ReactElement => {
const [value, setValue] = useState(null);
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
@@ -44,4 +38,11 @@ export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactEleme
);
};
AsyncSelectPaginate.propTypes = {
metronResource: PropTypes.string.isRequired,
placeholder: PropTypes.string,
value: PropTypes.object,
onChange: PropTypes.func,
};
export default AsyncSelectPaginate;

View File

@@ -1,75 +1,45 @@
import React, { useState, ReactElement, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import Card from "../shared/Carda";
import Card from "../Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { RawFileDetails } from "./RawFileDetails";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import TabControls from "./TabControls";
import { EditMetadataPanel } from "./EditMetadataPanel";
import { Menu } from "./ActionMenu/Menu";
import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select";
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
import AcquisitionPanel from "./AcquisitionPanel";
import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash";
import { RootState } from "threetwo-ui-typings";
import "react-sliding-pane/dist/react-sliding-pane.css";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import Loader from "react-loader-spinner";
import SlidingPane from "react-sliding-pane";
import Modal from "react-modal";
import ComicViewer from "react-comic-viewer";
import { extractComicArchive } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { styled } from "styled-components";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
// Extracted modules
import { useComicVineMatching } from "./useComicVineMatching";
import { createTabConfig } from "./tabConfig";
import { actionOptions, customStyles, ActionOption } from "./actionMenuConfig";
import { CVMatchesPanel, EditMetadataPanelWrapper } from "./SlidingPanelContent";
// Styled component - moved outside to prevent recreation
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
type InferredIssue = {
name?: string;
number?: number;
year?: string;
subtitle?: string;
[key: string]: any;
};
type ComicVineMetadata = {
name?: string;
volumeInformation?: any;
[key: string]: any;
};
type Acquisition = {
directconnect?: {
downloads?: any[];
};
torrent?: any[];
[key: string]: any;
};
type ComicDetailProps = {
data: {
_id: string;
rawFileDetails?: RawFileDetailsType;
inferredMetadata: {
issue?: InferredIssue;
};
sourcedMetadata: {
comicvine?: ComicVineMetadata;
locg?: any;
comicInfo?: any;
};
acquisition?: Acquisition;
createdAt: string;
updatedAt: string;
};
userSettings?: any;
queryClient?: any;
comicObjectId?: string;
};
type ComicDetailProps = {};
/**
* Displays full comic detail: cover, file info, action menu, and tabbed panels
* for metadata, archive operations, and acquisition.
* Component for displaying the metadata for a comic in greater detail.
*
* @component
* @example
* return (
* <ComicDetail/>
* )
*/
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const {
data: {
@@ -77,61 +47,110 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
rawFileDetails,
inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
createdAt,
},
userSettings,
queryClient,
comicObjectId: comicObjectIdProp,
} = data;
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const comicVineSearchQueryObject = useSelector(
(state: RootState) => state.comicInfo.searchQuery,
);
const comicVineAPICallProgress = useSelector(
(state: RootState) => state.comicInfo.inProgress,
);
const extractedComicBook = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.reading,
);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Action event handlers
const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
const dispatch = useDispatch();
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
const openModal = useCallback((filePath) => {
setIsOpen(true);
dispatch(
extractComicArchive(filePath, {
type: "full",
purpose: "reading",
imageResizeOptions: {
baseWidth: 1024,
},
}),
);
}, []);
// Action menu handler
const Placeholder = components.Placeholder;
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const afterOpenModal = useCallback((things) => {
// references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
console.log("kolaveri", things);
}, []);
const handleActionSelection = (action: ActionOption) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
break;
}
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// sliding panel init
const contentForSlidingPanel = {
CVMatches: {
content: (props) => (
<>
<div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
</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>
)}
</>
),
},
editComicBookMetadata: {
content: () => <EditMetadataPanel />,
},
};
// Check for metadata availability
// check for the availability of CV metadata
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
const { issueName, url } = determineCoverFile({
rawFileDetails,
@@ -139,110 +158,158 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
locg,
});
// Query for airdc++
// query for airdc++
const airDCPPQuery = {
issue: {
name: issueName,
},
};
// Create tab configuration
const tabGroup = createTabConfig({
data: data.data,
comicInfo,
isComicBookMetadataAvailable,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId: _id,
userSettings,
issueName,
acquisition,
});
// Tab content and header details
const tabGroup = [
{
id: 1,
name: "Volume Information",
icon: <i className="fa-solid fa-layer-group"></i>,
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>,
content: (
<div className="columns" key={2}>
<div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
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",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
) : (
"Downloads"
),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
),
shouldShow: true,
},
];
// filtered Tabs
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
// Sliding panel content mapping
const renderSlidingPanelContent = () => {
switch (slidingPanelContentId) {
case "CVMatches":
return (
<CVMatchesPanel
rawFileDetails={rawFileDetails}
inferredMetadata={inferredMetadata}
comicVineMatches={comicVineMatches}
comicObjectId={comicObjectId || _id}
queryClient={queryClient}
onMatchApplied={() => {
setVisible(false);
setActiveTab(1);
}}
/>
);
case "editComicBookMetadata":
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
default:
return null;
}
};
// Determine which cover image to use:
// 1. from the locally imported or
// 2. from the CV-scraped version
return (
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<section className="container">
<div className="section">
{!isNil(data) && !isEmpty(data) && (
<>
<div>
<div className="flex flex-row mt-5">
<h1 className="title">{issueName}</h1>
<div className="columns is-multiline">
<div className="column is-narrow">
<Card
imageUrl={url}
orientation={"cover-only"}
orientation={"vertical"}
hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }}
/>
{/* raw file details */}
{/* action dropdown */}
<div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div>
</div>
{/* raw file details */}
<div className="column">
{!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails?.cover) && (
<div className="grid">
!isEmpty(rawFileDetails.cover) && (
<>
<RawFileDetails
data={{
rawFileDetails,
inferredMetadata,
createdAt,
rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata,
}}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
>
{/* 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,
}}
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
<Modal
style={{ content: { marginTop: "2rem" } }}
isOpen={modalIsOpen}
onAfterOpen={afterOpenModal}
onRequestClose={closeModal}
contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{closeButton: "border: 1px solid red;"}}
/>
</div>
</RawFileDetails>
</div>
)}
</Modal>
</>
)}
</div>
</div>
<TabControls
filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
{<TabControls filteredTabs={filteredTabs} />}
<StyledSlidingPanel
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
width={"600px"}
>
{renderSlidingPanelContent()}
</StyledSlidingPanel>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</>
)}
</div>
@@ -250,4 +317,4 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
);
};
export default ComicDetail;
export default ComicDetail;

View File

@@ -1,40 +1,22 @@
import React, { ReactElement } from "react";
import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQueryClient } from "@tanstack/react-query";
import { useGetComicByIdQuery } from "../../graphql/generated";
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
export const ComicDetailContainer = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const queryClient = useQueryClient();
const {
data: comicBookDetailData,
isLoading,
isError,
} = useGetComicByIdQuery(
{ id: comicObjectId! },
{ enabled: !!comicObjectId }
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const dispatch = useDispatch();
if (isError) {
return <div>Error loading comic details</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
const adaptedData = comicBookDetailData?.comic
? adaptGraphQLComicToLegacy(comicBookDetailData.comic)
: null;
return adaptedData ? (
<ComicDetail
data={adaptedData}
queryClient={queryClient}
comicObjectId={comicObjectId}
/>
const { comicObjectId } = useParams<{ comicObjectId: string }>();
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
) : null;
};

View File

@@ -1,118 +1,119 @@
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs";
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 => {
import { isUndefined } from "lodash";
import Card from "../Carda";
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (
<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>
</div>
<div className="column is-half">
<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>
{/* 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>
<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>
</div>
<div>
{data.issue_number && (
<div className="">
<span>Issue Number</span>
<span>{data.issue_number}</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>
)}
{!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>
</dd>
</dl>
</div>
</div>
</div>
</div>
</dd>
</dl>
</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,
}),
};

View File

@@ -1,53 +1,23 @@
import React, { ReactElement } from "react";
import MatchResult from "./MatchResult";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "../MatchResult";
import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
interface ComicVineMatchPanelProps {
props: {
comicObjectId: string;
comicVineMatches: any[];
queryClient?: any;
onMatchApplied?: () => void;
};
}
/** Displays ComicVine search results or a status message while searching. */
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
const { comicvine } = useStore(
useShallow((state) => ({
comicvine: state.comicvine,
})),
);
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const {
comicObjectId,
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
} = comicVineData.props;
return (
<>
<div>
{!isEmpty(comicVineMatches) ? (
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<MatchResult
matchData={comicVineMatches}
matchData={comicVineSearchResults}
comicObjectId={comicObjectId}
queryClient={queryClient}
onMatchApplied={onMatchApplied}
/>
) : (
<>
<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>
</>

View File

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

View File

@@ -1,127 +1,33 @@
import prettyBytes from "pretty-bytes";
import React, { ReactElement, useEffect, useRef, useState } from "react";
import { useStore } from "../../store";
import type { Socket } from "socket.io-client";
/**
* @typedef {Object} DownloadProgressTickProps
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
*/
interface DownloadProgressTickProps {
bundleId: string;
}
/**
* Shape of the download tick data received over the socket.
*
* @typedef DownloadTickData
* @property {number} id - Unique download ID
* @property {string} name - File name (e.g. "movie.mkv")
* @property {number} downloaded_bytes - Bytes downloaded so far
* @property {number} size - Total size in bytes
* @property {number} speed - Current download speed (bytes/sec)
* @property {number} seconds_left - Estimated seconds remaining
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
* @property {{ online: number; total: number; str: string }} sources
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
*/
interface DownloadTickData {
id: number;
name: string;
downloaded_bytes: number;
size: number;
speed: number;
seconds_left: number;
status: {
id: string;
str: string;
completed: boolean;
downloaded: boolean;
failed: boolean;
hook_error: any;
};
sources: {
online: number;
total: number;
str: string;
};
target: string;
}
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
bundleId,
}): ReactElement | null => {
const socketRef = useRef<Socket>();
const [tick, setTick] = useState<DownloadTickData | null>(null);
useEffect(() => {
const socket = useStore.getState().getSocket("manual");
socketRef.current = socket;
socket.emit("call", "socket.listenFileProgress", {
namespace: "/manual",
config: {
protocol: `ws`,
hostname: `192.168.1.119:5600`,
username: `admin`,
password: `password`,
},
});
/**
* Handler for each "downloadTick" event.
* Only update state if event.id matches bundleId.
*
* @param {DownloadTickData} data - Payload from the server
*/
const onDownloadTick = (data: DownloadTickData) => {
// Compare numeric data.id to string bundleId
if (data.id === parseInt(bundleId, 10)) {
setTick(data);
}
};
socket.on("downloadTick", onDownloadTick);
return () => {
socket.off("downloadTick", onDownloadTick);
};
}, [socketRef, bundleId]);
if (!tick) {
return <>Nothing detected.</>;
}
// Compute human-readable values and percentages
const downloaded = prettyBytes(tick.downloaded_bytes);
const total = prettyBytes(tick.size);
const percent = tick.size > 0
? Math.round((tick.downloaded_bytes / tick.size) * 100)
: 0;
const speed = prettyBytes(tick.speed) + "/s";
const minutesLeft = Math.round(tick.seconds_left / 60);
import React, { ReactElement } from "react";
export const DownloadProgressTick = (props): ReactElement => {
return (
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
{/* Downloaded vs Total */}
<div className="mt-1 flex items-center space-x-2">
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
<div >
<h4 className="is-size-6">{props.data.name}</h4>
<div>
<span className="is-size-3 has-text-weight-semibold">
{prettyBytes(props.data.downloaded_bytes)} of{" "}
{prettyBytes(props.data.size)}{" "}
</span>
<progress
className="progress is-small is-success"
value={props.data.downloaded_bytes}
max={props.data.size}
>
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
100}
%
</progress>
</div>
{/* Progress bar */}
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-green-500"
style={{ width: `${percent}%` }}
/>
<div className="is-size-5">
{prettyBytes(props.data.speed)} per second.
</div>
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
{/* Speed and Time Left */}
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
<span>Speed: {speed}</span>
<span>Time left: {minutesLeft} min</span>
<div className="is-size-5">
Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)}
</div>
<div>{props.data.target}</div>
</div>
);
};

View File

@@ -1,154 +1,106 @@
import React, { useEffect, ReactElement, useState, useMemo } from "react";
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,
} from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
export interface TorrentDetails {
infoHash: string;
progress: number;
downloadSpeed?: number;
uploadSpeed?: number;
interface IDownloadsPanelProps {
data: any;
comicObjectId: string;
}
/**
* DownloadsPanel displays two tabs of download information for a specific comic:
* - DC++ (AirDCPP) bundles
* - Torrent downloads
* It also listens for real-time torrent updates via a WebSocket.
*
* @component
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
*/
export const DownloadsPanel = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
"directconnect",
);
const { socketIOInstance } = useStore(
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
);
/**
* Registers socket listeners on mount and cleans up on unmount.
*/
useEffect(() => {
if (!socketIOInstance) return;
/**
* Handler for incoming torrent data events.
* Merges new entries or updates existing ones by infoHash.
*
* @param {TorrentDetails} data - Payload from the socket event.
*/
const handleTorrentData = (data: TorrentDetails) => {
setTorrentDetails((prev) => {
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
if (idx === -1) {
return [...prev, data];
}
const next = [...prev];
next[idx] = { ...next[idx], ...data };
return next;
});
};
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
return () => {
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
};
}, [socketIOInstance]);
// ————— DC++ Bundles (via REST) —————
const { data: bundles } = useQuery({
queryKey: ["bundles", comicObjectId],
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`,
},
},
}),
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
const bundles = useSelector((state: RootState) => {
return state.airdcpp.bundles;
});
// ————— Torrent Jobs (via REST) —————
const { data: rawJobs = [] } = useQuery<any[]>({
queryKey: ["torrents", comicObjectId],
queryFn: async () => {
const { data } = await axios.get(
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
{ params: { trigger: activeTab } },
);
return Array.isArray(data) ? data : [];
},
initialData: [],
enabled: activeTab === "torrents",
});
// AirDCPP Socket initialization
const userSettings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => {
if (activeTab !== "torrents") return;
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
}, [activeTab]);
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]);
return (
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
return !isNil(props.data) ? (
<>
<div className="mt-5 mb-3">
<nav className="flex space-x-2">
<button
onClick={() => setActiveTab("directconnect")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "directconnect"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
DC++
</button>
<button
onClick={() => setActiveTab("torrents")}
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
activeTab === "torrents"
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
Torrents
</button>
</nav>
<div className="mt-4">
{activeTab === "torrents" ? (
<TorrentDownloads data={torrentDetails} />
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
<AirDCPPBundles data={bundles.data} />
) : (
<p>No DC++ bundles found.</p>
)}
</div>
<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;
};
export default DownloadsPanel;

View File

@@ -1,41 +1,55 @@
import React, { ReactElement } from "react";
import { Form, Field, FieldRenderProps } from "react-final-form";
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";
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
import TextareaAutosize from "react-textarea-autosize";
interface EditMetadataPanelProps {
data: {
name?: string;
[key: string]: any;
};
}
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Sliding panel form for manually editing comic metadata fields. */
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
const onSubmit = async () => {};
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const rawFileDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
);
const dispatch = useDispatch();
return (
<>
<Form
onSubmit={onSubmit}
mutators={{ ...arrayMutators }}
validate={validate}
mutators={{
...arrayMutators,
}}
render={({
handleSubmit,
form: {
mutators: { push, pop },
},
}, // injected from final-form-arrays above
pristine,
form,
submitting,
values,
}) => (
<form onSubmit={handleSubmit}>
{/* Issue Name */}
@@ -44,59 +58,93 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
<label className="label">Issue Details</label>
</div>
<div className="field-body">
<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 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>
</div>
</div>
{/* Issue Number and year */}
<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>
<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 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>
{/* year */}
<div className="field">
<p className="control">
<Field
name="issue_year"
component="input"
className="input"
/>
</p>
</div>
</div>
</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"
/>
{/* 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="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 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>
<hr />
<hr size="1" />
<div className="field is-horizontal">
<div className="field-label">
@@ -136,7 +184,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
</div>
</div>
<hr />
<hr size="1" />
{/* Publisher */}
<div className="field is-horizontal">
@@ -207,7 +255,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
</div>
</div>
<hr />
<hr size="1" />
{/* team credits */}
<div className="field is-horizontal">
@@ -285,6 +333,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
))
}
</FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form>
)}
/>

View File

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

View File

@@ -1,116 +1,106 @@
import React, { ReactElement, ReactNode } from "react";
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash";
import { format, parseISO, isValid } from "date-fns";
import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
type RawFileDetailsProps = {
data?: {
rawFileDetails?: RawFileDetailsType;
inferredMetadata?: InferredMetadata;
createdAt?: string;
};
children?: ReactNode;
};
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata } = props.data;
return (
<>
<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) ? (
<div className="comic-detail raw-file-details column is-three-fifths">
<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">
{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">
{inferredMetadata?.issue?.number}
{rawFileDetails.extension}
</span>
) : null}
</dd>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">MIME type</span>
<span className="tag is-primary is-light">
{rawFileDetails.mimeType}
</span>
</div>
</div>
</div>
<div 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>
</dd>
</dl>
</div>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.mimeType}
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
<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>
</span>
</dd>
</div>
</div>
{!isEmpty(inferredMetadata.issue.number) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary is-light">
{inferredMetadata.issue.number}
</span>
</div>
</div>
) : null}
</div>
<div 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">
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
</span>
</span>
</dd>
</div>
<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">
{createdAt && isValid(parseISO(createdAt)) ? (
<>
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(createdAt), "h aaaa")}
</>
) : "N/A"}
</dd>
</div>
<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>
</dd>
</dl>
</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,
mimeType: PropTypes.string,
cover: PropTypes.shape({
filePath: PropTypes.string,
}),
}),
inferredMetadata: PropTypes.shape({
issue: PropTypes.shape({
year: PropTypes.string,
name: PropTypes.string,
number: PropTypes.number,
subtitle: PropTypes.string,
}),
}),
}),
};

View File

@@ -1,65 +0,0 @@
import React from "react";
import { ComicVineSearchForm } from "./ComicVineSearchForm";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { EditMetadataPanel } from "./EditMetadataPanel";
import { RawFileDetails } from "../../graphql/generated";
type InferredIssue = {
name?: string;
number?: number;
year?: string;
subtitle?: string;
[key: string]: any;
};
type CVMatchesPanelProps = {
rawFileDetails?: RawFileDetails;
inferredMetadata: {
issue?: InferredIssue;
};
comicVineMatches: any[];
comicObjectId: string;
queryClient: any;
onMatchApplied: () => void;
};
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
rawFileDetails,
inferredMetadata,
comicVineMatches,
comicObjectId,
queryClient,
onMatchApplied,
}) => (
<>
<div>
<ComicVineSearchForm data={rawFileDetails} />
</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,
queryClient,
onMatchApplied,
}}
/>
</>
);
type EditMetadataPanelWrapperProps = {
rawFileDetails?: RawFileDetails;
};
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
rawFileDetails,
}) => <EditMetadataPanel data={rawFileDetails} />;

View File

@@ -1,57 +1,50 @@
import React, { ReactElement, Suspense, useState } from "react";
import { isNil } from "lodash";
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { useSelector } from "react-redux";
export const TabControls = (props): ReactElement => {
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const { filteredTabs } = props;
const [active, setActive] = useState(filteredTabs[0].id);
// Use controlled state if provided, otherwise use internal state
const currentActive = activeTab !== undefined ? activeTab : active;
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
useEffect(() => {
setActive(filteredTabs[0].id);
}, [comicBookDetailData]);
return (
<>
<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 ${
currentActive === id
? "border-b border-cyan-50 dark:text-slate-200"
: "border-b border-transparent"
}`}
aria-current="page"
onClick={() => handleSetActive(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" />
<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}
</span>
) : (
<span className="w-5 h-5">{icon}</span>
)}
{name}
</>
</span>
) : (
<span className="icon is-small">{icon}</span>
)}
{name}
</a>
))}
</nav>
</div>
</li>
))}
</ul>
</div>
<Suspense>
{filteredTabs.map(({ id, content }) => {
return currentActive === id ? content : null;
})}
</Suspense>
{filteredTabs.map(({ id, content }) => {
return active === id ? content : null;
})}
</>
);
};

View File

@@ -1,155 +1,59 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD";
import React, { ReactElement, useCallback, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { DnD } from "../../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: { data: any }): ReactElement => {
export const ArchiveOperations = (props): ReactElement => {
const { data } = props;
const isComicBookExtractionInProgress = useSelector(
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
);
const extractedComicBookArchive = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
);
const imageAnalysisResult = useSelector((state: RootState) => {
return state.fileOps.imageAnalysisResults;
});
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => {
dispatch(
extractComicArchive(data.rawFileDetails.filePath, {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
}),
);
}, []);
const getSocket = useStore((state) => state.getSocket);
const queryClient = useQueryClient();
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
const [currentImage, setCurrentImage] = useState<string>("");
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
useState(false);
const constructImagePaths = (data: string[]): Array<string> => {
return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
);
};
// Listen to the uncompression complete event and orchestrate the final payload
useEffect(() => {
const socket = getSocket("/");
if (!socket) return;
const handleUncompressionComplete = (data: any) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
};
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
return () => {
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
};
}, [getSocket]);
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: any) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
});
const uncompressedArchive = constructImagePaths(paths);
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
setShouldRefetchComicBookData(true);
}
},
});
} catch (error) {
// Error handling could be added here if needed
}
};
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);
}
const [currentImage, setCurrentImage] = useState([]);
// sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
const contentForSlidingPanel = {
imageAnalysis: {
content: () => {
return (
<div>
<pre className="text-sm">{currentImage}</pre>
<pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3">
<pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
<pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre>
</div>
);
@@ -158,83 +62,56 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
};
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath);
dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<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"
<button
className={
isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
>
<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) ? (
<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) ? (
<DnD
data={uncompressedArchive}
data={extractedComicBookArchive}
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}
@@ -249,4 +126,4 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
);
};
export default ArchiveOperations;
export default ArchiveOperations;

View File

@@ -1,67 +1,56 @@
import { isUndefined } from "lodash";
import React, { ReactElement } from "react";
export const ComicInfoXML = (data: { json: any }): ReactElement => {
export const ComicInfoXML = (data): ReactElement => {
const { json } = data;
return (
<div className="flex w-3/4">
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
<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)}
<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]}
</span>
</span>
</dd>
)}
{/* Genre */}
{!isUndefined(json.genre) && (
<dd className="my-2">
<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>
<span className="text-slate-500 dark:text-slate-900">
{json.genre[0]}
</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>
</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>
)}
</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>
</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>
</dd>
<dd>
<span className="is-size-7">{json.notes[0]}</span>
</dd>
<dd className="mt-1 mb-1">
{!isUndefined(json.summary) && json.summary[0]}
</dd>
{!isUndefined(json.notes) && (
<dd>
{/* Notes */}
<span className="text-sm text-slate-500 dark:text-slate-900">
{json.notes[0]}
</span>
</dd>
)}
</dl>
</div>
);

View File

@@ -1,15 +1,30 @@
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}>
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
<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>
</div>
);
};

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
import React from "react";
import { StylesConfig } from "react-select";
export interface ActionOption {
value: string;
label: React.ReactElement;
}
export 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>
);
export 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>
);
export 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>
);
export const actionOptions: ActionOption[] = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
export const customStyles: StylesConfig<ActionOption, false> = {
menu: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base: any) => ({
...base,
color: "black",
}),
option: (base: any, { isFocused }: any) => ({
...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}),
singleValue: (base: any) => ({
...base,
paddingTop: "0.4rem",
}),
control: (base: any) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
color: "black",
border: "1px solid rgb(156, 163, 175)",
}),
};

View File

@@ -1,129 +0,0 @@
import React, { lazy } from "react";
import { isNil, isEmpty } from "lodash";
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
const ComicInfoXML = lazy(() => import("./Tabs/ComicInfoXML").then(m => ({ default: m.ComicInfoXML })));
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
const DownloadsPanel = lazy(() => import("./DownloadsPanel"));
interface TabConfig {
id: number;
name: string;
icon: React.ReactElement;
content: React.ReactElement | null;
shouldShow: boolean;
}
interface TabConfigParams {
data: any;
comicInfo: any;
isComicBookMetadataAvailable: boolean;
areRawFileDetailsAvailable: boolean;
airDCPPQuery: any;
comicObjectId: string;
userSettings: any;
issueName: string;
acquisition?: any;
}
export const createTabConfig = ({
data,
comicInfo,
isComicBookMetadataAvailable,
areRawFileDetailsAvailable,
airDCPPQuery,
comicObjectId,
userSettings,
issueName,
acquisition,
}: TabConfigParams): TabConfig[] => {
return [
{
id: 1,
name: "Volume Information",
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} key={1} />
) : null,
shouldShow: isComicBookMetadataAvailable,
},
{
id: 2,
name: "ComicInfo.xml",
icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: (
<div key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
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} key={3} />,
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
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}
comicObjectId={comicObjectId}
comicObject={data}
settings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
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>
),
name: "Torrent Search",
content: <TorrentSearchPanel comicObjectId={comicObjectId} issueName={issueName} />,
shouldShow: true,
},
{
id: 6,
name: "Downloads",
icon: (
<>
{(acquisition?.directconnect?.downloads?.length || 0) +
(acquisition?.torrent?.length || 0)}
</>
),
content:
!isNil(data) && !isEmpty(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,
},
];
};

View File

@@ -1,89 +0,0 @@
import { useState } from "react";
import axios from "axios";
import { isNil, isUndefined, isEmpty } from "lodash";
import { refineQuery } from "filename-parser";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
type ComicVineMatch = {
score: number;
[key: string]: any;
};
type ComicVineSearchQuery = {
inferredIssueDetails: {
name: string;
[key: string]: any;
};
[key: string]: any;
};
type ComicVineMetadata = {
name?: string;
[key: string]: any;
};
export const useComicVineMatching = () => {
const [comicVineMatches, setComicVineMatches] = useState<ComicVineMatch[]>([]);
const fetchComicVineMatches = async (
searchPayload: any,
issueSearchQuery: ComicVineSearchQuery,
seriesSearchQuery: ComicVineSearchQuery,
) => {
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;
},
});
let matches: ComicVineMatch[] = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match: ComicVineMatch) => match);
}
const scoredMatches = matches.sort((a: ComicVineMatch, b: ComicVineMatch) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
// Error handling could be added here if needed
}
};
const prepareAndFetchMatches = (
rawFileDetails: RawFileDetailsType | undefined,
comicvine: ComicVineMetadata | undefined,
) => {
let seriesSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
let issueSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
if (!isUndefined(rawFileDetails) && rawFileDetails.name) {
issueSearchQuery = refineQuery(rawFileDetails.name) as ComicVineSearchQuery;
} else if (!isEmpty(comicvine) && comicvine?.name) {
issueSearchQuery = refineQuery(comicvine.name) as ComicVineSearchQuery;
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
};
return {
comicVineMatches,
prepareAndFetchMatches,
};
};

View File

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

View File

@@ -1,72 +1,127 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
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 {
useGetRecentComicsQuery,
useGetWantedComicsQuery,
useGetVolumeGroupsQuery,
useGetLibraryStatisticsQuery
} from "../../graphql/generated";
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil } from "lodash";
import Header from "../Header";
export const Dashboard = (): ReactElement => {
// Use GraphQL for recent comics
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
{ limit: 5 },
{ refetchOnWindowFocus: false }
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchVolumeGroups());
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}),
);
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
comicStatus: "wanted",
}),
);
dispatch(getLibraryStatistics());
}, []);
const recentComics = useSelector(
(state: RootState) => state.fileOps.recentComics,
);
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const volumeGroups = useSelector(
(state: RootState) => state.fileOps.comicVolumeGroups,
);
// Wanted Comics - using GraphQL
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery(
{
paginationOptions: {
page: 1,
limit: 5,
sort: '{"updatedAt": -1}'
},
predicate: '{"acquisition.source.wanted": true}'
},
{
refetchOnWindowFocus: false,
retry: false
}
const libraryStatistics = useSelector(
(state: RootState) => state.comicInfo.libraryStatistics,
);
// Volume Groups - using GraphQL
const { data: volumeGroupsData, error: volumeGroupsError } = useGetVolumeGroupsQuery(
undefined,
{ refetchOnWindowFocus: false }
);
// Library Statistics - using GraphQL
const { data: statisticsData, error: statisticsError } = useGetLibraryStatisticsQuery(
undefined,
{
refetchOnWindowFocus: false,
retry: false
}
);
const recentComics = recentComicsData?.comics?.comics || [];
const wantedComics = !wantedComicsError ? (wantedComicsData?.getComicBooks?.docs || []) : [];
const volumeGroups = volumeGroupsData?.getComicBookGroups || [];
const statistics = !statisticsError ? statisticsData?.getLibraryStatistics : undefined;
return (
<>
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<PullList />
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
{/* Wanted comics */}
<WantedComicsList comics={wantedComics} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics} />}
{/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups} />
</div>
</>
<div className="container">
<section className="section">
<h1 className="title">Dashboard</h1>
{!isEmpty(recentComics) ? (
<>
{/* Pull List */}
<PullList issues={recentComics} />
<>
<div className="content mt-6">
<Header
headerContent="Import Activity"
subHeaderContent="Results aggregated from the last import"
iconClassNames="fa-solid fa-file-invoice mr-2"
/>
</div>
<table className="table">
<thead>
<tr>
<th>
<abbr title="Position">Pos</abbr>
</th>
<th>Team</th>
<th>
<abbr title="Played">Pld</abbr>
</th>
</tr>
</thead>
<tbody>
<tr>
<th>1</th>
<td>38</td>
</tr>
</tbody>
</table>
</>
{/* Stats */}
{!isEmpty(libraryStatistics) && (
<LibraryStatistics stats={libraryStatistics} />
)}
{/* Wanted comics */}
{!isEmpty(wantedComics) && (
<WantedComicsList comics={wantedComics} />
)}
{/* Recent imports */}
<RecentlyImported comicBookCovers={recentComics} />
{/* Volumes */}
{!isEmpty(volumeGroups) && (
<VolumeGroups volumeGroups={volumeGroups} />
)}
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
}
/>
)}
</section>
</div>
);
};

View File

@@ -1,105 +1,114 @@
import React, { ReactElement } from "react";
import Header from "../shared/Header";
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
comicDirectorySize: DirectorySize;
comicsMissingFiles: number;
};
/** Props for {@link LibraryStatistics}. */
interface LibraryStatisticsProps {
stats: Stats | null | undefined;
}
/**
* Displays a snapshot of library metrics: total comic files, tagging coverage,
* file-type breakdown, and the publisher with the most issues.
*
* Returns `null` when `stats` is absent or the statistics array is empty.
*/
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
if (!stats) return null;
const facet = stats.statistics?.[0];
if (!facet) return null;
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
const topPublisher = publisherWithMostComicsInLibrary?.[0];
import React, { ReactElement, useEffect } from "react";
import prettyBytes from "pretty-bytes";
import { isEmpty, isUndefined, map } from "lodash";
export const LibraryStatistics = (
props: ILibraryStatisticsProps,
): ReactElement => {
// const { stats } = props;
return (
<div className="mt-5">
<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 flex flex-row gap-5">
{/* Total records in database */}
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.totalDocuments} comics
</dd>
</div>
{/* Missing files */}
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
<dd className="text-3xl text-red-600 md:text-5xl">
{stats.comicsMissingFiles}
</dd>
</div>
{/* Disk space consumed */}
{stats.comicDirectorySize.totalSizeInGB != null && (
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
<dd className="text-3xl text-gray-700 md:text-5xl">
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
<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
</dd>
</div>
)}
{/* Tagging coverage */}
<div className="flex flex-col gap-4">
{issues && issues.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issues.length}</span>
tagged with ComicVine
</div>
)}
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
with ComicInfo.xml
</div>
)}
<dd className="is-size-4">
Library size
<span className="has-text-weight-bold">
{" "}
{props.stats.comicDirectorySize &&
prettyBytes(props.stats.comicDirectorySize)}
</span>
</dd>
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issues) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issues.length}
</span>{" "}
tagged with ComicVine
</dd>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics[0].issuesWithComicInfoXML) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{props.stats.statistics[0].issuesWithComicInfoXML.length}
</span>{" "}
with
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
ComicInfo.xml
</span>
</dd>
)}
</dl>
</div>
{/* File-type breakdown */}
{fileTypes && fileTypes.length > 0 && (
<div>
{fileTypes.map((ft) => (
<span
key={ft.id}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
>
{ft.data.length} {ft.id}
</span>
))}
</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>
{/* Publisher with most issues */}
{topPublisher && (
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
<span>{topPublisher.id}</span>
{" has the most issues "}
<span>{topPublisher.count}</span>
</div>
)}
{/* file types */}
<div className="p-3 column is-two-fifths">
{/* publisher with most issues */}
<dl className="box">
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
) && (
<dd className="is-size-6">
<span className="has-text-weight-bold">
{
props.stats.statistics[0]
.publisherWithMostComicsInLibrary[0]._id
}
</span>
{" has the most issues "}
<span className="has-text-weight-bold">
{
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>
);

View File

@@ -1,180 +1,160 @@
import React, { ReactElement, useState } from "react";
import { map } from "lodash";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../Carda";
import Header from "../Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions";
import 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import useEmblaCarousel from "embla-carousel-react";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import { Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
import { LocgMetadata, useGetWeeklyPullListQuery } from "../../graphql/generated";
type PullListProps = {
issues: any;
};
interface PullListProps {
issues?: LocgMetadata[];
}
export const PullList = (): ReactElement => {
const queryClient = useQueryClient();
// datepicker
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "yyyy/M/dd"),
export const PullList = ({ issues }: PullListProps): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2023-8-9",
pageSize: "15",
currentPage: "1",
}),
);
}, []);
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
);
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
const {
data: pullListData,
refetch,
isSuccess,
isLoading,
isError,
} = useGetWeeklyPullListQuery({
input: {
startDate: inputValue,
pageSize: 15,
currentPage: 1,
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
});
};
*/
// Transform the data to match the old structure
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined;
const { mutate: addToLibrary } = useMutation({
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => {
const comicBookMetadata = {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
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,
},
};
return await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: comicBookMetadata,
});
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => {
// sliderRef.slickNext();
sliderRef.slickNext();
};
const previous = () => {
// sliderRef.slickPrev();
sliderRef.slickPrev();
};
return (
<>
<Header
headerContent="Discover"
subHeaderContent={
<span className="text-md">
Pull List aggregated for the week from{" "}
<span className="underline">
<a href="https://leagueofcomicgeeks.com">
League Of Comic Geeks
</a>
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
}
iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/"
/>
<div className="flex flex-row gap-5 mb-3">
<div className="content">
<Header headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
iconClassNames="fa-solid fa-splotch mr-2"/>
<div className="field is-grouped">
{/* select week */}
<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 className="w-lvw -mr-4 sm:-mr-6 lg:-mr-8">
{isSuccess && !isLoading && (
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(pullList?.data.result, (issue: LocgMetadata, idx: number) => {
return (
<div
key={idx}
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
>
<Card
orientation={"vertical-2"}
imageUrl={issue.cover || undefined}
hasDetails
title={ellipsize(issue.name || 'Unknown', 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.publisher || 'Unknown Publisher'}
</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({ sourceName: "locg", metadata: { locg: issue } })}
>
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want
</button>
</div>
</div>
</Card>
</div>
);
})}
<div className="control">
<div className="select is-small">
<select>
<option>Select Week</option>
<option>With options</option>
</select>
</div>
</div>
)}
{isLoading && <div>Loading...</div>}
{isError && <div>An error occurred while retrieving the pull list.</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>
</div>
</div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({ 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>
<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>
);
})}
</Slider>
</>
);
};

View File

@@ -1,144 +1,146 @@
import React, { ReactElement } from "react";
import Card from "../shared/Carda";
import Card from "../Carda";
import { Link } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetRecentComicsQuery } from "../../graphql/generated";
import Masonry from "react-masonry-css";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
type RecentlyImportedProps = {
comics: GetRecentComicsQuery['comics']['comics'];
comicBookCovers: any;
};
export const RecentlyImported = (
{ comics }: RecentlyImportedProps,
): ReactElement => {
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
return (
<div>
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{comics?.map((comic, idx) => {
const {
id,
<>
<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,
(
{
_id,
rawFileDetails,
sourcedMetadata,
canonicalMetadata,
inferredMetadata,
importStatus,
} = comic;
// Parse sourced metadata (GraphQL returns as strings)
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
? JSON.parse(sourcedMetadata.comicvine)
: sourcedMetadata?.comicvine;
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
? JSON.parse(sourcedMetadata.comicInfo)
: sourcedMetadata?.comicInfo;
const locg = sourcedMetadata?.locg;
sourcedMetadata: { comicvine, comicInfo, locg },
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const isComicVineMetadataAvailable =
const { issue, coverURL, icon } = determineExternalMetadata(name, {
comicvine,
comicInfo,
locg,
});
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile
? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<div
key={idx}
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
>
<React.Fragment key={_id}>
<Card
orientation="vertical-2"
orientation={"vertical"}
imageUrl={url}
title={inferredMetadata?.issue?.name}
hasDetails
cardState={cardState}
title={issueName ? titleElement : null}
>
<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>
<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="/src/client/assets/img/missing-file.svg" />
</span>
<span className="text-md text-slate-900">
{inferredMetadata?.issue?.number}
</span>
</span>
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails?.extension}
</span>
</span>
{/* Uncompressed status */}
{rawFileDetails?.archive?.uncompressed ? (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-4 h-4"></i>
</span>
</span>
) : null}
</dd>
</div>
<div className="flex flex-row items-center gap-1 mt-2 pb-1">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
)}
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<div className="mt-1">
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-gray-500 dark:text-white-300"></i>
</div>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
<span className="icon custom-icon is-small has-text-danger">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-full h-full object-contain"
src="/src/client/assets/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
)}
{/* ComicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</div>
</Card>
</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={`/src/client/assets/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
},
)}
</Masonry>
</>
);
};
};

View File

@@ -2,81 +2,62 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetVolumeGroupsQuery } from "../../graphql/generated";
import Masonry from "react-masonry-css";
type VolumeGroupsProps = {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
};
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate();
const navigateToVolumes = (row: any) => {
const navigateToVolumes = (row) => {
navigate(`/volumes/all`);
};
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return (
<div>
<Header
headerContent="Volumes"
subHeaderContent={<>Based on ComicVine Volume information</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/volumes"}
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(deduplicatedGroups, (data) => {
return (
<div
key={data.id}
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
>
<Card
orientation="vertical-2"
imageUrl={data.volumes?.image?.small_url || undefined}
hasDetails
>
<div className="py-3">
<div className="text-sm">
<Link to={`/volume/details/${data.id}`}>
{ellipsize(data.volumes?.name || 'Unknown', 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 || 0} issues
</span>
</span>
</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>
);
})}
</div>
</div>
<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>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
{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>
</span>
</div>
</div>
</div>
);
})}
</Masonry>
</section>
);
};

View File

@@ -1,136 +1,114 @@
import React, { ReactElement } from "react";
import Card from "../shared/Carda";
import Card from "../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";
import useEmblaCarousel from "embla-carousel-react";
import { GetWantedComicsQuery } from "../../graphql/generated";
type WantedComicsListProps = {
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
comics: any;
};
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate();
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return (
<div>
<Header
headerContent="Wanted Comics"
subHeaderContent={<>Comics marked as wanted from various sources</>}
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"}
/>
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{map(
comics,
(comic) => {
const {
id,
rawFileDetails,
sourcedMetadata,
} = comic;
<>
<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"
>
{map(
comics,
({
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
}) => {
const isComicBookMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
// Parse sourced metadata (GraphQL returns as strings)
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
? JSON.parse(sourcedMetadata.comicvine)
: sourcedMetadata?.comicvine;
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
? JSON.parse(sourcedMetadata.comicInfo)
: sourcedMetadata?.comicInfo;
const locg = sourcedMetadata?.locg;
const isComicBookMetadataAvailable = !isUndefined(comicvine);
const consolidatedComicMetadata = {
rawFileDetails,
comicvine,
comicInfo,
locg,
};
const {
issueName,
url,
publisher = null,
} = determineCoverFile(consolidatedComicMetadata);
const titleElement = (
<Link to={"/comic/details/" + id}>
{ellipsize(issueName, 20)}
<p>{publisher}</p>
</Link>
);
return (
<div
key={id}
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
>
<Card
orientation={"vertical-2"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
cardState="wanted"
>
<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}
{/* Wanted comics - info not available in current GraphQL query */}
</div>
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div>
</Card>
</div>
);
},
)}
</div>
</div>
</div>
</div>
const { issueName, url } = determineCoverFile(
consolidatedComicMetadata,
);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<Card
key={_id}
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/locglogo.svg" />
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
);
},
)}
</Masonry>
</>
);
};

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
import React, { ReactElement, useEffect, useState } from "react";
import React, {
ReactElement,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { isEmpty, isNil, isUndefined } from "lodash";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel";
@@ -9,18 +17,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(() => {
@@ -57,12 +65,13 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
}, [issueBundles]);
return !isNil(bundles) ? (
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="container">
<section className="section">
<h1 className="title">Downloads</h1>
<div className="columns">
<div className="column is-half">
{bundles.map((bundle, idx) => {
console.log(bundle);
return (
<div key={idx}>
<MetadataPanel

View File

@@ -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 "../shared/Carda";
import Card from "../Carda";
import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel";

View File

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

View File

@@ -0,0 +1,20 @@
import React, { ReactElement } from "react";
type IHeaderProps = {
headerContent: string;
subHeaderContent: string;
iconClassNames: string;
};
export const Header = (props: IHeaderProps): ReactElement => {
return (
<>
<h4 className="title is-4">
<i className={props.iconClassNames}></i> {props.headerContent}
</h4>
<p className="subtitle is-7">{props.subHeaderContent}</p>
</>
);
};
export default Header;

View File

@@ -0,0 +1,237 @@
import React, { ReactElement, useCallback, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
fetchComicBookMetadata,
getImportJobResultStatistics,
setQueueControl,
} from "../actions/fileops.actions";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns";
import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash";
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 successfulImportJobCount = useSelector(
(state: RootState) => state.fileOps.successfulJobCount,
);
const failedImportJobCount = useSelector(
(state: RootState) => state.fileOps.failedJobCount,
);
const lastQueueJob = useSelector(
(state: RootState) => state.fileOps.lastQueueJob,
);
const libraryQueueImportStatus = useSelector(
(state: RootState) => state.fileOps.LSQueueImportStatus,
);
const allImportJobResults = useSelector(
(state: RootState) => state.fileOps.importJobStatistics,
);
const initiateImport = useCallback(() => {
if (typeof props.path !== "undefined") {
dispatch(fetchComicBookMetadata(props.path));
}
}, [dispatch]);
const toggleQueue = useCallback(
(queueAction: string, queueStatus: string) => {
dispatch(setQueueControl(queueAction, queueStatus));
},
[],
);
useEffect(() => {
dispatch(getImportJobResultStatistics());
}, []);
const renderQueueControls = (status: string): ReactElement | null => {
switch (status) {
case "running":
return (
<div className="control">
<button
className="button is-warning is-light"
onClick={() => toggleQueue("pause", "paused")}
>
<i className="fa-solid fa-pause mr-2"></i> Pause
</button>
</div>
);
case "paused":
return (
<div className="control">
<button
className="button is-success is-light"
onClick={() => toggleQueue("resume", "running")}
>
<i className="fa-solid fa-play mr-2"></i> Resume
</button>
</div>
);
case "drained":
return null;
default:
return null;
}
};
return (
<div className="container">
<section className="section is-small">
<h1 className="title">Import Comics</h1>
<article className="message is-dark">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Import Comics
</span>
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>
<p className="buttons">
<button
className={
libraryQueueImportStatus === "drained" ||
libraryQueueImportStatus === undefined
? "button is-medium"
: "button is-loading is-medium"
}
onClick={initiateImport}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Start Import</span>
</button>
</p>
{libraryQueueImportStatus !== "drained" &&
!isUndefined(libraryQueueImportStatus) && (
<>
<table className="table">
<thead>
<tr>
<th>Completed Jobs</th>
<th>Failed Jobs</th>
<th>Queue Controls</th>
<th>Queue Status</th>
</tr>
</thead>
<tbody>
<tr>
<th>
{successfulImportJobCount > 0 && (
<div className="box has-background-success-light has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{successfulImportJobCount}
</span>
</div>
)}
</th>
<td>
{failedImportJobCount > 0 && (
<div className="box has-background-danger has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{failedImportJobCount}
</span>
</div>
)}
</td>
<td>{renderQueueControls(libraryQueueImportStatus)}</td>
<td>
{libraryQueueImportStatus !== undefined ? (
<span className="tag is-warning">
{libraryQueueImportStatus}
</span>
) : null}
</td>
</tr>
</tbody>
</table>
Imported{" "}
<span className="has-text-weight-bold">{lastQueueJob}</span>
</>
)}
{/* Past imports */}
<h3 className="subtitle is-4 mt-5">Past Imports</h3>
<table className="table">
<thead>
<tr>
<th>Time Started</th>
<th>Session Id</th>
<th>Imported</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{allImportJobResults.map((jobResult, id) => {
return (
<tr key={id}>
<td>
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td>
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td>
<span className="tag is-success">
{jobResult.completedJobs}
</span>
</td>
<td>
<span className="tag is-danger">
{jobResult.failedJobs}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</section>
</div>
);
};
export default Import;

View File

@@ -1,493 +0,0 @@
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { Import } from './Import';
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.MockedFunction<any>;
// Mock zustand store
const mockGetSocket = jest.fn();
const mockDisconnectSocket = jest.fn();
const mockSetStatus = jest.fn();
jest.mock('../../store', () => ({
useStore: jest.fn((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
),
}));
// Mock socket.io-client
const mockSocket = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
};
mockGetSocket.mockReturnValue(mockSocket);
// Helper function to create a wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Import Component - Numerical Indices', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should display numerical indices in the Past Imports table', async () => {
// Mock API response with 3 import sessions
const mockData = [
{
sessionId: 'session-1',
earliestTimestamp: '2024-01-01T10:00:00Z',
completedJobs: 5,
failedJobs: 0
},
{
sessionId: 'session-2',
earliestTimestamp: '2024-01-02T10:00:00Z',
completedJobs: 3,
failedJobs: 1
},
{
sessionId: 'session-3',
earliestTimestamp: '2024-01-03T10:00:00Z',
completedJobs: 8,
failedJobs: 2
},
];
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for the "Past Imports" heading to appear
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify that the "#" column header exists
expect(screen.getByText('#')).toBeInTheDocument();
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
const rows = screen.getAllByRole('row');
// Skip header row (index 0), check data rows
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
});
test('should display correct indices for larger datasets', async () => {
// Mock API response with 10 import sessions
const mockData = Array.from({ length: 10 }, (_, i) => ({
sessionId: `session-${i + 1}`,
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
completedJobs: i + 1,
failedJobs: 0,
}));
(axios as any).mockResolvedValue({ data: mockData });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Past Imports')).toBeInTheDocument();
});
// Verify indices 1 through 10 are present in the first column
const rows = screen.getAllByRole('row');
// Skip header row (index 0)
for (let i = 1; i <= 10; i++) {
const row = rows[i];
const cells = row.querySelectorAll('td');
expect(cells[0]).toHaveTextContent(i.toString());
}
});
});
describe('Import Component - Button Visibility', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should show Start Import button when queue status is drained', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
// Verify Pause and Resume buttons are NOT visible
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
});
test('should show Start Import button when queue status is undefined', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('Start Import')).toBeInTheDocument();
});
});
test('should hide Start Import button and show Pause button when queue is running', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 5,
failedJobCount: 1,
mostRecentImport: 'Comic #123',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Pause')).toBeInTheDocument();
});
// Verify Import Activity section is visible
expect(screen.getByText('Import Activity')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
});
test('should hide Start Import button and show Resume button when queue is paused', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'paused',
successfulJobCount: 3,
failedJobCount: 0,
mostRecentImport: 'Comic #456',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
expect(screen.getByText('Resume')).toBeInTheDocument();
});
});
});
describe('Import Component - SessionId and Socket Reconnection', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
localStorage.clear();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
jest.useRealTimers();
});
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
// Setup: Set old sessionId in localStorage
localStorage.setItem('sessionId', 'old-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is cleared immediately
expect(localStorage.getItem('sessionId')).toBeNull();
// Verify disconnectSocket is called
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
// Fast-forward 100ms
await act(async () => {
jest.advanceTimersByTime(100);
});
// Verify getSocket is called after 100ms
await waitFor(() => {
expect(mockGetSocket).toHaveBeenCalledWith('/');
});
// Fast-forward another 500ms
await act(async () => {
jest.advanceTimersByTime(500);
});
// Verify initiateImport is called and status is set to running
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: null },
});
expect(mockSetStatus).toHaveBeenCalledWith('running');
});
});
test('should NOT clear sessionId when starting import with undefined status', async () => {
// Setup: Set existing sessionId in localStorage
localStorage.setItem('sessionId', 'existing-session-id');
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: undefined,
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Click the "Start Import" button
const startButton = await screen.findByText('Start Import');
fireEvent.click(startButton);
// Verify sessionId is NOT cleared
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
// Verify disconnectSocket is NOT called
expect(mockDisconnectSocket).not.toHaveBeenCalled();
// Verify status is set to running immediately
expect(mockSetStatus).toHaveBeenCalledWith('running');
// Verify initiateImport is called immediately (no delay)
await waitFor(() => {
expect(axios.request).toHaveBeenCalledWith({
url: 'http://localhost:3000/api/library/newImport',
method: 'POST',
data: { sessionId: 'existing-session-id' },
});
});
});
});
describe('Import Component - Real-time Updates', () => {
beforeEach(() => {
jest.clearAllMocks();
(axios as any).mockResolvedValue({ data: [] });
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
});
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
});
// Get the event handler that was registered
const coverExtractedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_COVER_EXTRACTED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (coverExtractedHandler) {
coverExtractedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'running',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for component to mount and socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
// Get the event handler that was registered
const queueDrainedHandler = mockSocket.on.mock.calls.find(
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
)?.[1];
// Clear previous axios calls
(axios as any).mockClear();
// Simulate the socket event
if (queueDrainedHandler) {
queueDrainedHandler();
}
// Verify that the API is called again (refetch)
await waitFor(() => {
expect(axios).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
})
);
});
});
test('should cleanup socket listeners on unmount', async () => {
const { useStore } = require('../../store');
useStore.mockImplementation((selector: any) =>
selector({
importJobQueue: {
status: 'drained',
successfulJobCount: 0,
failedJobCount: 0,
mostRecentImport: '',
setStatus: mockSetStatus,
},
getSocket: mockGetSocket,
disconnectSocket: mockDisconnectSocket,
})
);
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
// Wait for socket listeners to be attached
await waitFor(() => {
expect(mockSocket.on).toHaveBeenCalled();
});
// Unmount the component
unmount();
// Verify that socket listeners are removed
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
});
});
export {};

View File

@@ -1,308 +0,0 @@
import { ReactElement, useEffect, useRef, useState } from "react";
import { format } from "date-fns";
import { isEmpty } from "lodash";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
export const Import = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
})),
);
// Force re-import mutation - re-imports all files regardless of import status
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
mutationFn: async () => {
const sessionId = localStorage.getItem("sessionId") || "";
return await axios.request({
url: `http://localhost:3000/api/library/forceReImport`,
method: "POST",
data: { sessionId },
});
},
onSuccess: (response) => {
console.log("Force re-import initiated:", response.data);
importJobQueue.setStatus("running");
setImportError(null);
},
onError: (error: any) => {
console.error("Failed to start force re-import:", error);
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
},
});
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
const importSession = useImportSessionStatus();
const hasActiveSession = importSession.isActive;
const wasComplete = useRef(false);
// React to importSession.isComplete rather than socket events — more reliable
// since it's derived from the actual GraphQL state, not a raw socket event.
useEffect(() => {
if (importSession.isComplete && !wasComplete.current) {
wasComplete.current = true;
// Small delay so the backend has time to commit job result stats
setTimeout(() => {
// Invalidate the cache to force a fresh fetch of job result statistics
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
refetch();
}, 1500);
importJobQueue.setStatus("drained");
} else if (!importSession.isComplete) {
wasComplete.current = false;
}
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
// Listen to socket events to update Past Imports table in real-time
useEffect(() => {
const socket = getSocket("/");
const handleImportCompleted = () => {
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
const handleQueueDrained = () => {
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
// Small delay to ensure backend has committed the job results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
}, 1500);
};
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
};
}, [getSocket, queryClient]);
/**
* Handles force re-import - re-imports all files to fix indexing issues
*/
const handleForceReImport = async () => {
setImportError(null);
// Check for active session before starting using definitive status
if (hasActiveSession) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
);
return;
}
if (window.confirm(
"This will re-import ALL files in your library folder, even those already imported. " +
"This can help fix Elasticsearch indexing issues. Continue?"
)) {
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setTimeout(() => {
forceReImport();
}, 500);
}, 100);
} else {
forceReImport();
}
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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>
{/* Import Statistics */}
<div className="my-6 max-w-screen-lg">
<RealTimeImportStats />
</div>
{/* Error Message */}
{importError && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Import Error
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
{importError}
</p>
</div>
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</span>
</button>
</div>
</div>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<div className="my-6 max-w-screen-lg">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleForceReImport}
disabled={isForceReImporting || hasActiveSession}
title="Re-import all files to fix Elasticsearch indexing issues"
>
<span className="text-md font-medium">
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
</span>
</button>
</div>
)}
{/* Import activity is now shown in the RealTimeImportStats component above */}
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
<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">
#
</th>
<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?.getJobResultStatistics.map((jobResult: any, index: number) => {
return (
<tr key={index}>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
{index + 1}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
? format(
new Date(parseInt(jobResult.earliestTimestamp)),
"EEEE, hh:mma, do LLLL y",
)
: "N/A"}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700">
<span className="h-5 w-6">
<i className="icon-[solar--check-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.completedJobs}
</p>
</span>
</td>
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300">
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-2 py-0.5 text-red-700">
<span className="h-5 w-6">
<i className="icon-[solar--close-circle-line-duotone] h-5 w-5"></i>
</span>
<p className="whitespace-nowrap text-sm">
{jobResult.failedJobs}
</p>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</section>
</div>
);
};
export default Import;

View File

@@ -1,379 +0,0 @@
import { ReactElement, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
useGetImportStatisticsQuery,
useGetWantedComicsQuery,
useStartIncrementalImportMutation,
} from "../../graphql/generated";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
/**
* Import statistics with card-based layout and progress bar.
* Three states: pre-import (idle), importing (active), and post-import (complete).
* Also surfaces missing files detected by the file watcher.
*/
export const RealTimeImportStats = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const [detectedFile, setDetectedFile] = useState<string | null>(null);
const [socketImport, setSocketImport] = useState<{
active: boolean;
completed: number;
total: number;
failed: number;
} | null>(null);
const queryClient = useQueryClient();
const { getSocket, disconnectSocket, importJobQueue } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
importJobQueue: state.importJobQueue,
})),
);
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false },
);
const stats = importStats?.getImportStatistics?.stats;
// File list for the detail panel — only fetched when there are missing files
const { data: missingComicsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 3, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{
refetchOnWindowFocus: false,
refetchInterval: false,
enabled: (stats?.missingFiles ?? 0) > 0,
},
);
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
const getMissingComicLabel = (comic: any): string => {
const series =
comic.canonicalMetadata?.series?.value ??
comic.inferredMetadata?.issue?.name;
const issueNum =
comic.canonicalMetadata?.issueNumber?.value ??
comic.inferredMetadata?.issue?.number;
if (series && issueNum) return `${series} #${issueNum}`;
if (series) return series;
return comic.rawFileDetails?.name ?? comic.id;
};
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } =
useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
setImportError(
error?.message || "Failed to start import. Please try again.",
);
},
});
const hasNewFiles = stats && stats.newFiles > 0;
const missingCount = stats?.missingFiles ?? 0;
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
useEffect(() => {
const socket = getSocket("/");
const handleStatsChange = () => {
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
};
const handleFileDetected = (payload: { filePath: string }) => {
handleStatsChange();
const name = payload.filePath.split("/").pop() ?? payload.filePath;
setDetectedFile(name);
setTimeout(() => setDetectedFile(null), 5000);
};
const handleImportStarted = () => {
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
};
const handleCoverExtracted = (payload: {
completedJobCount: number;
totalJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) => ({
active: true,
completed: payload.completedJobCount,
total: payload.totalJobCount,
failed: prev?.failed ?? 0,
}));
};
const handleCoverExtractionFailed = (payload: {
failedJobCount: number;
importResult: unknown;
}) => {
setSocketImport((prev) =>
prev ? { ...prev, failed: payload.failedJobCount } : null,
);
};
const handleQueueDrained = () => {
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
handleStatsChange();
};
socket.on("LS_LIBRARY_STATS", handleStatsChange);
socket.on("LS_FILES_MISSING", handleStatsChange);
socket.on("LS_FILE_DETECTED", handleFileDetected);
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
return () => {
socket.off("LS_LIBRARY_STATS", handleStatsChange);
socket.off("LS_FILES_MISSING", handleStatsChange);
socket.off("LS_FILE_DETECTED", handleFileDetected);
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
};
}, [getSocket, queryClient]);
const handleStartImport = async () => {
setImportError(null);
if (importSession.isActive) {
setImportError(
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
);
return;
}
if (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setTimeout(() => {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}, 500);
}, 100);
} else {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}
};
if (isLoading || !stats) {
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
}
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`
: `Start Incremental Import (${stats.newFiles} new files)`;
// Determine what to show in each card based on current phase
const sessionStats = importSession.stats;
const hasSessionStats = importSession.isActive && sessionStats !== null;
const totalFiles = stats.totalLocalFiles;
const importedCount = stats.alreadyImported;
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
const showProgressBar = socketImport !== null;
const socketProgressPct =
socketImport && socketImport.total > 0
? Math.round((socketImport.completed / socketImport.total) * 100)
: 0;
const showFailedCard = hasSessionStats && failedCount > 0;
const showMissingCard = missingCount > 0;
return (
<div className="space-y-6">
{/* Error Message */}
{importError && (
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-red-800 dark:text-red-300">
Import Error
</p>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
{importError}
</p>
</div>
<button
onClick={() => setImportError(null)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
>
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</button>
</div>
</div>
)}
{/* File detected toast */}
{detectedFile && (
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
New file detected: {detectedFile}
</p>
</div>
)}
{/* Start Import button — only when idle with new files */}
{hasNewFiles && !importSession.isActive && (
<button
onClick={handleStartImport}
disabled={isStartingImport}
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
>
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
</button>
)}
{/* Progress bar — shown while importing and once complete */}
{showProgressBar && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{socketImport!.active
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
: `${socketImport!.completed} / ${socketImport!.total} imported`}
</span>
<span className="font-semibold text-gray-900 dark:text-white">
{socketProgressPct}% complete
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
style={{ width: `${socketProgressPct}%` }}
>
{socketImport!.active && (
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
)}
</div>
</div>
</div>
)}
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Total files */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#6b7280" }}
>
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
<div className="text-sm text-gray-200 font-medium">in import folder</div>
</div>
{/* Imported */}
<div
className="rounded-lg p-6 text-center"
style={{ backgroundColor: "#d8dab2" }}
>
<div className="text-4xl font-bold text-gray-800 mb-2">
{importedCount}
</div>
<div className="text-sm text-gray-700 font-medium">
{importSession.isActive ? "imported so far" : "imported in database"}
</div>
</div>
{/* Failed — only shown after a session with failures */}
{showFailedCard && (
<div className="rounded-lg p-6 text-center bg-red-500">
<div className="text-4xl font-bold text-white mb-2">
{failedCount}
</div>
<div className="text-sm text-red-100 font-medium">failed</div>
</div>
)}
{/* Missing files — shown when watcher detects moved/deleted files */}
{showMissingCard && (
<div className="rounded-lg p-6 text-center bg-card-missing">
<div className="text-4xl font-bold text-slate-700 mb-2">
{missingCount}
</div>
<div className="text-sm text-slate-800 font-medium">missing</div>
</div>
)}
</div>
{/* Missing files detail panel */}
{showMissingCard && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-800 dark:text-amber-300">
{missingCount} {missingCount === 1 ? "file" : "files"} missing
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
These files were previously imported but can no longer be found
on disk. Move them back to restore access.
</p>
{missingDocs.length > 0 && (
<ul className="mt-2 space-y-1">
{missingDocs.map((comic, i) => (
<li
key={i}
className="text-xs text-amber-700 dark:text-amber-400 truncate"
>
{getMissingComicLabel(comic)} is missing
</li>
))}
{missingCount > 3 && (
<li className="text-xs text-amber-600 dark:text-amber-500">
and {missingCount - 3} more.
</li>
)}
</ul>
)}
<Link
to="/library?filter=missingFiles"
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
>
<span className="underline">
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
View Missing Files In Library
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
</span>
</Link>
</div>
</div>
</div>
)}
</div>
);
};
export default RealTimeImportStats;

View File

@@ -1,226 +1,117 @@
import React, { useMemo, ReactElement, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import React, { useMemo, ReactElement, useCallback, 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 SearchBar from "../Library/SearchBar";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, parseISO } from "date-fns";
import { useGetWantedComicsQuery } from "../../graphql/generated";
type FilterOption = "all" | "missingFiles";
interface SearchQuery {
query: Record<string, any>;
pagination: { size: number; from: number };
type: string;
trigger: string;
}
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: "all", label: "All Comics" },
{ value: "missingFiles", label: "Missing Files" },
];
/**
* Library page component. Displays a paginated, searchable table of all comics
* in the collection, with an optional filter for comics with missing raw files.
* Component that tabulates the contents of the user's ThreeTwo Library.
*
* @component
* @example
* <Library />
*/
export const Library = (): ReactElement => {
const [searchParams] = useSearchParams();
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
query: {},
pagination: { size: 25, from: 0 },
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/** Fetches a page of issues from the search API. */
const fetchIssues = async (q: SearchQuery) => {
const { pagination, query, type } = q;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: { query, pagination, type },
});
};
const { data, isPlaceholderData } = useQuery({
queryKey: ["comics", searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
enabled: activeFilter === "all",
});
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 25, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "missingFiles" },
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",
},
),
);
}, []);
const { data: missingIdsData } = useGetWantedComicsQuery(
{
paginationOptions: { limit: 1000, page: 1 },
predicate: { "importStatus.isRawFileMissing": true },
},
{ enabled: activeFilter === "all" },
);
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
const missingIdSet = useMemo(
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
[missingIdsData],
);
const searchResults = data?.data;
// programatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
/** Triggers a search by volume name and resets pagination. */
const searchIssues = (e: any) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: { volumeName: e.search },
pagination: { size: 15, from: 0 },
type: "volumeName",
trigger: "libraryPage",
});
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
};
/** Advances to the next page of results. */
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from: pageSize * pageIndex + 1 },
type: "all",
trigger: "libraryPage",
});
}
};
/** Goes back to the previous page of results. */
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: { size: 15, from },
type: "all",
trigger: "libraryPage",
});
};
const ComicInfoXML = (value: any) =>
value.data ? (
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
{ellipsize(value.data.series[0], 25)}
</span>
</span>
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
Pages: {value.data.pagecount[0]}
const ComicInfoXML = (value) => {
return value.data ? (
<div className="comicvine-metadata mt-3">
<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)}
</span>
</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-0.5 sm:pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm: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 missingFilesColumns = useMemo(
() => [
{
header: "Missing Files",
columns: [
{
header: "Status",
id: "missingStatus",
cell: () => (
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
MISSING
</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>
</div>
),
},
{
header: "Comic",
id: "missingComic",
minWidth: 250,
accessorFn: (row: any) => row,
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
},
],
},
],
[],
);
</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>
) : 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: 250,
minWidth: 400,
accessorKey: "_source",
cell: (info: any) => {
const source = info.getValue();
return (
<MetadataPanel
data={source}
isMissing={missingIdSet.has(info.row.original._id)}
/>
);
cell: (info) => {
return <MetadataPanel data={info.getValue()} />;
},
},
{
header: "ComicInfo.xml",
accessorKey: "_source.sourcedMetadata.comicInfo",
cell: (info: any) =>
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
align: "center",
minWidth: 250,
cell: (info) =>
!isEmpty(info.getValue()) ? (
<ComicInfoXML data={info.getValue()} />
) : (
<span className="tag mt-5">No ComicInfo.xml</span>
),
},
],
},
@@ -228,131 +119,138 @@ export const Library = (): ReactElement => {
header: "Additional Metadata",
columns: [
{
header: "Date of Import",
accessorKey: "_source.createdAt",
cell: (info: any) =>
!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: "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: "Downloads",
accessorKey: "_source.acquisition",
cell: (info: any) => (
<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 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
</span>
DC++: {info.getValue().directconnect.downloads.length}
</span>
<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 whitespace-nowrap">
<span className="pr-1 pt-1">
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
</span>
Torrent: {info.getValue().torrent.length}
</span>
</div>
),
header: "Something",
accessorKey: "_source.acquisition.source.wanted",
cell: (info) => {
!isUndefined(info.getValue()) ? (
<WantedStatus value={info.getValue().toString()} />
) : (
"Nothing"
);
},
},
],
},
],
[missingIdSet],
[],
);
const FilterDropdown = () => (
<div className="relative">
<select
value={activeFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
</div>
);
/**
* Pagination control that fetches the next x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @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: "all",
trigger: "libraryPage",
},
),
);
}, []);
const isMissingFilter = activeFilter === "missingFiles";
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
* @param {number} pageIndex
* @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: "all",
trigger: "libraryPage",
},
),
);
}, []);
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
// };
return (
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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>
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Library</h1>
</div>
{!isEmpty(searchResults) ? (
<div>
<div className="library">
<T2Table
totalPages={searchResults.total.value}
columns={columns}
sourceData={searchResults?.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
previousPage,
}}
/>
</div>
</div>
</div>
</header>
{isMissingFilter ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{isMissingLoading ? (
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
) : (
<T2Table
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
columns={missingFilesColumns}
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
rowClickHandler={navigateToMissingComicDetail}
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
>
<FilterDropdown />
</T2Table>
)}
</div>
) : !isUndefined(searchResults?.hits) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
getRowClassName={(row) =>
missingIdSet.has(row.original._id)
? "bg-card-missing/40 hover:bg-card-missing/20"
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
}
paginationHandlers={{ nextPage, previousPage }}
>
<div className="flex items-center gap-2">
<FilterDropdown />
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
) : (
<div className="columns">
<div className="column is-two-thirds">
<article className="message is-link">
<div className="message-body">
No comics were found in the library, Elasticsearch reports no
indices. Try importing a few comics into the library and come
back.
</div>
</article>
<pre>
{!isUndefined(searchError.data) &&
JSON.stringify(
searchError.data.meta.body.error.root_cause,
null,
4,
)}
</pre>
</div>
</T2Table>
</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>
<FilterDropdown />
</div>
)}
</div>
)}
</div>
</section>
);
};

View File

@@ -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 "../shared/Carda";
import Card from "../Carda";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
@@ -72,11 +72,8 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
>
<div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && (
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
<img
src="/src/client/assets/img/cvlogo.svg"
className="w-full h-full object-contain"
/>
<span className="icon cv-icon is-small">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{isNil(rawFileDetails) && (

View File

@@ -1,47 +1,63 @@
import React, { ReactElement } from "react";
import React, { ReactElement, useCallback } from "react";
import PropTypes from "prop-types";
import { 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 = (props): ReactElement => {
const { searchHandler } = props;
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",
},
),
);
}, []);
return (
<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>
)}
/>
<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>
);
};

View File

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

View File

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

View File

@@ -1,23 +1,25 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import T2Table from "../shared/T2Table";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import Card from "../shared/Carda";
import { useDispatch, useSelector } from "react-redux";
import Card from "../Carda";
import ellipsize from "ellipsize";
import { 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: "2023-7-28",
// pageSize: "15",
// currentPage: "1",
// }),
// );
dispatch(
getWeeklyPullList({
startDate: "2023-7-28",
pageSize: "15",
currentPage: "1",
}),
);
}, []);
const nextPageHandler = () => {};
const previousPageHandler = () => {};
@@ -99,7 +101,7 @@ export const PullList = (): ReactElement => {
[],
);
return (
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Weekly Pull List</h1>

View File

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

View File

@@ -1,471 +0,0 @@
import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util";
import PopoverButton from "../shared/PopoverButton";
import dayjs from "dayjs";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {}
export const Search = ({}: ISearchProps): ReactElement => {
const queryClient = useQueryClient();
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,
});
// 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:
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 },
},
},
});
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
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="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form
onSubmit={onSubmit}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<div className="flex flex-row w-full">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
<div className="w-10 text-gray-400">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<button
className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
Search
</button>
</div>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form>
)}
/>
</div>
{isPending && (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Loading results...
</div>
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{comicVineSearchResults.data.results.map((result) => {
return result.resource_type === "issue" ? (
<div
key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
{result.cover_date && (
<p>
<span className="tag is-light">Cover date</span>
{dayjs(result.cover_date).format("MMM D, YYYY")}
</p>
)}
<p className="tag is-warning">{result.id}</p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`This will add ${result.volume.name} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
</div>
</div>
</div>
) : (
result.resource_type === "volume" && (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>}
</div>
<div className="flex flex-row gap-2">
{/* issue count */}
{result.count_of_issues && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{t("issueWithCount", {
count: result.count_of_issues,
})}
</span>
</span>
</div>
)}
{/* type: TPB, one-shot, graphic novel etc. */}
{!isNil(result.description) &&
!isUndefined(result.description) && (
<>
{!isEmpty(
detectIssueTypes(result.description),
) && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
</div>
)}
</>
)}
</div>
<span className="tag is-warning">{result.id}</span>
<p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<PopoverButton
content={`Adding this volume will add ${t(
"issueWithCount",
{
count: result.count_of_issues,
},
)} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: true,
resourceType: "volume",
})
}
/>
</div>
</div>
</div>
</div>
)
);
})}
</div>
) : (
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or
torrent clients.
</p>
</div>
</article>
</div>
)}
</section>
</div>
);
};
export default Search;

View File

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

View File

@@ -1,173 +0,0 @@
import React, { ReactElement, useState } from "react";
import { Form, Field } from "react-final-form";
import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { produce } from "immer";
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient();
const {
data: settings,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
staleTime: Infinity,
});
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () =>
await axios({
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
method: "POST",
data: {
host: settings?.data.directConnect?.client?.host,
},
}),
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
let hubList: any[] = [];
if (!isNil(hubs)) {
hubList = hubs?.data.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
const mutation = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: {
settingsPayload: values,
settingsObjectId: settings?.data._id,
settingsKey: "directConnect",
},
}),
onSuccess: (data) => {
queryClient.setQueryData(["settings"], (oldData: any) =>
produce(oldData, (draft: any) => {
draft.data.directConnect.client = {
...draft.data.directConnect.client,
...data.data.directConnect.client,
};
}),
);
},
});
const validate = async (values) => {
const errors = {};
// Add any validation logic here if needed
return errors;
};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error loading settings.</div>;
}
return (
<>
{!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form
onSubmit={(values) => {
mutation.mutate(values);
}}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} className="mt-10">
<h2 className="text-xl">Configure DC++ Hubs</h2>
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against. Your
selection in the dropdown <strong>will replace</strong> the
existing selection.
</h6>
</article>
<div className="field">
<label className="block py-1 mt-3">AirDC++ Host</label>
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
<button
type="submit"
className="flex space-x-1 sm:mt-5 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
>
Submit
</button>
</form>
)}
/>
) : (
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
<div className="message-body">
No configured hubs detected in AirDC++. <br />
Configure to a hub in AirDC++ and then select a default hub here.
</div>
</article>
)}
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
<>
<div className="mt-4">
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary"></div>
</article>
</div>
<div>
<span className="flex items-center mt-10 mb-4">
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
Default Hub for Searches
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
{settings?.data.directConnect?.client.hubs.map(
({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</div>
</div>
</>
) : null}
</>
);
};
export default AirDCPPHubsForm;

View File

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

View File

@@ -1,88 +0,0 @@
import React, { useState, useEffect } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useMutation, useQuery, useQueryClient } 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) => {
return axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: values,
settingsKey: "directConnect",
});
},
onSuccess: async (response) => {
const host = response?.data?.directConnect?.client?.host;
if (host) {
const response = await fetchSessionInfo(host);
setAirDCPPSessionInformation(response.data);
// setState({ airDCPPClientConfiguration: host });
}
},
});
const deleteSettingsMutation = useMutation(() =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
return (
<>
<ConnectionForm
initialData={initFormData}
submitHandler={mutate}
formHeading={"Configure AirDC++"}
/>
{airDCPPSessionInformation && (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
)}
{settingsData?.data && (
<p className="control mt-4">
<button
className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()}
>
Delete
</button>
</p>
)}
</>
);
};
export default AirDCPPSettingsForm;

View File

@@ -1,37 +0,0 @@
import React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
export const DockerVars = (): React.ReactElement => {
const [environmentVariables, setEnvironmentVariables] = React.useState<
Record<string, string>
>({});
const { data } = useQuery({
queryKey: ["docker-vars"],
queryFn: async () => {
await axios({
method: "GET",
url: "http://localhost:3000/api/settings/getEnvironmentVariables",
}).then((response) => {
setEnvironmentVariables(response.data);
});
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
<p className="text-gray-600 dark:text-gray-400">
<pre>
{Object.entries(environmentVariables).length > 0
? JSON.stringify(environmentVariables, null, 2)
: "No environment variables found."}
</pre>
</p>
{/* Add your form or content for Docker environment variables here */}
</div>
);
};
export default DockerVars;

View File

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

View File

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

View File

@@ -1,146 +0,0 @@
import React, { useState, ReactElement } from "react";
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
import DockerVars from "./DockerVars/DockerVars";
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");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpanded = (id: string) => {
setExpanded((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const settingsContent = [
{ id: "adc-hubs", content: <AirDCPPHubsForm /> },
{ id: "adc-connection", content: <AirDCPPSettingsForm /> },
{id: "gen-docker-vars", content: <DockerVars />},
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> },
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> },
{ id: "core-service", content: <>a</> },
{ id: "flushdb", content: <SystemSettingsForm /> },
];
return (
<div>
<section>
{/* Header */}
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-6 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
Settings
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
{/* Main Layout */}
<div className="flex gap-8 px-12 py-6">
{/* Sidebar */}
<div className="relative z-30">
<aside
className="sticky top-6 w-72 max-h-[90vh]
rounded-2xl shadow-xl backdrop-blur-md
bg-white/70 dark:bg-slate-800/60
border border-slate-200 dark:border-slate-700
overflow-hidden"
>
<div className="px-4 py-6 overflow-y-auto">
{map(settingsObject, (settingObject, idx) => (
<div
key={idx}
className="mb-6 text-slate-700 dark:text-slate-300"
>
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
{settingObject.category.toUpperCase()}
</h3>
{!isUndefined(settingObject.children) && (
<ul>
{map(settingObject.children, (item, idx) => {
const isOpen = expanded[item.id];
return (
<li key={idx} className="mb-1">
<div
onClick={() => toggleExpanded(item.id)}
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
item.id === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
<span
onClick={() => setActive(item.id.toString())}
className="flex-1"
>
{item.displayName}
</span>
{!isUndefined(item.children) && (
<span className="text-xs opacity-60">
{isOpen ? "" : "+"}
</span>
)}
</div>
{!isUndefined(item.children) && isOpen && (
<ul className="pl-4 mt-1">
{map(item.children, (subItem) => (
<li key={subItem.id} className="mb-1">
<a
onClick={() =>
setActive(subItem.id.toString())
}
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
subItem.id.toString() === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
{subItem.displayName}
</a>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</aside>
</div>
{/* Content */}
<main className="flex-1 px-2 py-2">
{settingsContent.map(({ id, content }) =>
active === id ? <div key={id}>{content}</div> : null,
)}
</main>
</div>
</section>
</div>
);
};
export default Settings;

View File

@@ -1,16 +1,15 @@
import React, { ReactElement } from "react";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import React, { ReactElement, useCallback } from "react";
import { flushDb } from "../../actions/settings.actions";
import { useDispatch, useSelector } from "react-redux";
export const SystemSettingsForm = (): ReactElement => {
const { mutate: flushDb, isLoading } = useMutation({
mutationFn: async () => {
await axios({
url: `http://localhost:3000/api/library/flushDb`,
method: "POST",
});
},
});
const dispatch = useDispatch();
const isSettingsCallInProgress = useSelector(
(state: RootState) => state.settings.inProgress,
);
const flushDatabase = useCallback(() => {
dispatch(flushDb());
}, []);
return (
<div className="is-clearfix">
@@ -48,11 +47,15 @@ export const SystemSettingsForm = (): ReactElement => {
</article>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => flushDb()}
className={
isSettingsCallInProgress
? "button is-danger is-loading"
: "button is-danger"
}
onClick={flushDatabase}
>
<span className="pt-1 px-1">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
<span className="icon">
<i className="fas fa-eraser"></i>
</span>
<span>Flush DB & Temporary Folders</span>
</button>

View File

@@ -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 "../shared/Carda";
import { Card } from "../Carda";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";

View File

@@ -1,25 +1,30 @@
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
import React, { ReactElement, useState, useCallback } from "react";
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
import React, { useEffect, ReactElement, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router";
import { analyzeLibrary } from "../../actions/comicinfo.actions";
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
import {
getComicBookDetailById,
getIssuesForSeries,
analyzeLibrary,
} from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches";
import { Card } from "../shared/Carda";
import Masonry from "react-masonry-css";
import { Card } from "../Carda";
import SlidingPane from "react-sliding-pane";
import { 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
@@ -28,9 +33,7 @@ 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} />;
},
},
};
@@ -42,145 +45,68 @@ const VolumeDetails = (props): ReactElement => {
setVisible(true);
}, []);
// const analyzeIssues = useCallback((issues) => {
// dispatch(analyzeLibrary(issues));
// }, []);
//
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 { 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(issuesForSeries) ? (
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
{!isUndefined(issuesForVolume) ? (
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
Analyze Library
</div>
) : null}
<>
{isSuccess &&
issuesForSeries.data.map((issue) => {
return (
<>
<Masonry
breakpointCols={breakpointColumnsObj}
className="issues-container"
columnClassName="issues-column"
>
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
? issuesForVolume.map((issue) => {
return (
<Card
key={issue.id}
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>
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>
</>
);
@@ -189,44 +115,20 @@ const VolumeDetails = (props): ReactElement => {
{
id: 1,
name: "Issues in Volume",
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
content: <Issues />,
icon: <i className="fa-solid fa-layer-group"></i>,
content: <IssuesInVolume key={1} />,
},
{
id: 2,
icon: (
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
),
icon: <i className="fa-regular fa-mask"></i>,
name: "Characters",
content: <div key={2}>Characters</div>,
content: <div key={2}>asdasd</div>,
},
{
id: 3,
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>
),
icon: <i className="fa-solid fa-scroll"></i>,
name: "Arcs",
content: <div key={3}>asdasd</div>,
},
];
@@ -234,26 +136,21 @@ const VolumeDetails = (props): ReactElement => {
const MetadataTabGroup = () => {
return (
<>
<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>
<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>
{name}
</a>
))}
</nav>
</div>
</li>
))}
</ul>
</div>
{tabGroup.map(({ id, content }) => {
return active === id ? content : null;
@@ -261,103 +158,97 @@ const VolumeDetails = (props): ReactElement => {
</>
);
};
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
const { sourcedMetadata } = comicObject.data;
return (
<>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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 px-4 sm:px-6 lg:px-8 mt-4">
<div>
<div className="flex flex-row gap-4">
{/* Volume cover */}
if (
!isUndefined(comicBookDetails.sourcedMetadata) &&
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
) {
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">
<Card
imageUrl={
sourcedMetadata.comicvine.volumeInformation.image.small_url
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.image.small_url
}
orientation={"cover-only"}
cardContainerStyle={{ maxWidth: 275 }}
orientation={"vertical"}
hasDetails={false}
/>
</div>
<div>
<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">
{
sourcedMetadata.comicvine.volumeInformation.publisher
.name
}
</span>
</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>
{/* Deck */}
<div>
{!isEmpty(
sourcedMetadata.comicvine.volumeInformation.description,
)
? ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation
.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
{/* 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>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
{/* Deck */}
<div>
{!isEmpty(
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
.description,
)
? ellipsize(
convert(
comicBookDetails.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
300,
)
: null}
</div>
</div>
<MetadataTabGroup />
</div>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
</div>
<MetadataTabGroup />
</div>
</>
<SlidingPane
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Potential Matches in Library"}
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</div>
);
} else {
return <></>;

View File

@@ -1,28 +1,22 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import Card from "../shared/Carda";
import Card from "../Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
import { isUndefined } from "lodash";
export const Volumes = (props): ReactElement => {
// 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: {
const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 25,
from: 0,
@@ -30,59 +24,106 @@ export const Volumes = (props): ReactElement => {
type: "volumes",
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
),
);
}, []);
const columnData = useMemo(
(): any => [
() => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorFn: (row) => row,
cell: (row): any => {
const comicObject = row.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
accessorKey: "_source",
cell: (row) => {
const foo = row.getValue();
return (
<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 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>
<p>
{ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
180,
)}
</p>
</div>
</div>
);
},
},
{
header: "Other Details",
header: "Download Status",
columns: [
{
header: "Downloads",
header: "Files",
accessorKey: "_source.acquisition.directconnect",
align: "right",
cell: (props) => {
@@ -103,34 +144,12 @@ export const Volumes = (props): ReactElement => {
},
},
{
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: "Air",
},
{
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>
);
},
header: "Type",
id: "dcc",
},
],
},
@@ -138,45 +157,28 @@ export const Volumes = (props): ReactElement => {
[],
);
return (
<div>
<section className="">
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 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 className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Volumes</h1>
</div>
{!isUndefined(volumes.hits) && (
<div>
<div className="library">
<T2Table
sourceData={volumes?.data.hits.hits}
totalPages={volumes?.data.hits.hits.length}
sourceData={volumes?.hits}
totalPages={volumes.hits.length}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
}}
rowClickHandler={() => {}}
columns={columnData}
/>
</div>
</div>
) : null}
{isError ? (
<div>An error was encountered while retrieving volumes</div>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
)}
</div>
</section>
);
};

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