Compare commits

..

7 Commits

Author SHA1 Message Date
d36138c800 Update App.scss 2023-12-03 22:46:49 -05:00
1ed6a622d4 🌜 Trying dark mode on the react-select 2023-12-03 16:02:54 -05:00
29e0772a10 🌜 Initial Dark Mode support 2023-12-03 15:28:05 -05:00
57b713aca1 🏗️ Refactored the AirDC++ download panel 2023-12-02 11:38:17 -05:00
dfd99e45b6 🔧 Implementing download method 2023-11-29 23:24:34 -05:00
591ecb394c 🔧 Formatted the search query box 2023-11-29 23:08:00 -05:00
145427d3fd 🏗️ Acquisition Panel refactor WIP 2023-11-29 21:22:48 -05:00
139 changed files with 12933 additions and 38685 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1 +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,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -7,7 +7,7 @@
<title>Three Two!</title> <title>Three Two!</title>
</head> </head>
<body class="dark:bg-slate-600"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/client/index.tsx"></script> <script type="module" src="/src/client/index.tsx"></script>
</body> </body>

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;

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"
}

21441
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -55,6 +55,7 @@ export const toggleAirDCPPSocketConnectionStatus =
break; break;
default: default:
console.log("Can't set AirDC++ socket status.");
break; break;
} }
}; };

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,639 @@
@import "/node_modules/bulma/bulma.sass";
@import "/node_modules/bulma-prefers-dark/bulma-prefers-dark.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;
body {
background: #20292f;
}
.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;
}
.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: hsl(232, 11%, 15%);
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: inset 0 0 0.5px 1px hsla(0, 0%, 100%, 0.1),
/* 2. shadow ring 👇 */ 0 0 0 1px hsla(230, 13%, 9%, 0.075),
/* 3. multiple soft shadows 👇 */ 0 0.3px 0.4px hsla(230, 13%, 9%, 0.02),
0 0.9px 1.5px hsla(230, 13%, 9%, 0.045),
0 3.5px 6px hsla(230, 13%, 9%, 0.09);
.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: #30475e;
border-radius: 0.5rem;
}
.comic-viewer {
border: 1px solid red;
}
// comicvine metadata
.comicvine-metadata {
background-color: #d6cc99;
padding: 0.8rem;
border-radius: 0.5rem;
}
.issue-metadata {
background-color: #57615c;
padding: 0.8em;
border-radius: 0.5rem;
.name {
font-size: 0.95rem;
}
}
.comicInfo-metadata {
background-color: #d6cc99;
color: #000;
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 {
background: #20292f;
width: 100%;
padding: 25px 0 15px 0;
position: sticky;
z-index: 9;
top: 57px;
}
.library {
.table-controls {
background: #20292f;
justify-content: space-between;
position: sticky;
top: 126px;
padding-bottom: 10px;
}
.pagination {
margin: 0;
}
table {
background: #20292f;
border-collapse: separate;
width: 100%;
thead {
background: #20292f;
position: sticky;
top: 250px;
z-index: 1;
min-height: 130px;
}
tr {
td {
border: 0 none;
.card {
margin: 8px 0 7px 0;
.name {
margin: 0 0 4px 0;
}
}
}
}
tbody {
padding: 10px 0 0 0;
}
}
}
// Comic Detail
.control-palette {
background-color: #fff6de;
display: inline-block;
i {
display: flex;
justify-content: center;
align-items: center;
// padding: 1.5rem 2rem;
}
}
// airdcpp downloads tab
.tabs {
.download-icon-labels {
.downloads-count {
margin: 0 1em -1px 0.4em;
border: 1px solid #ccc;
}
}
.download-tab-name {
}
}
// drawer content padding override
.slide-pane__content {
padding: 24px 12px;
}
.slide-pane__header {
margin-top: 3.5rem;
}
.comic-vine-match-drawer {
// comic detail drawer
.search-criteria-card {
width: 100%;
.card-content {
padding: 10px;
.ant-divider-horizontal {
margin: 12px 0;
}
}
}
.field {
margin: 5px 0 0 0;
}
}
// Volume detail
.volume-details {
.is-volume-related {
$tag-background-color: $volume-color;
}
.issues-container {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -10px; /* gutter size offset */
width: auto;
.issues-column {
max-width: 102px;
margin: 10px;
background-clip: padding-box;
& > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
}
}
}
// Potential issue matches in library slideout panel
.potential-matches-container {
.potential-issue-match {
border-radius: 0.3rem;
background-color: beige;
padding: 10px;
pre {
padding: 5px;
background-color: transparent;
border-radius: 0.3rem;
white-space: pre-wrap; /* Since CSS 2.1 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word;
}
.generic-card {
max-width: 90px;
}
}
}
// comicvine search results
.search-results-container {
margin: 15px 0 0 0;
overflow: hidden;
.search-result {
margin: 0 0 10px 0;
padding: 1em;
border-radius: 10px;
background: #f2f1f9;
.cover-image {
border-radius: 5px;
}
.search-result-details {
.score {
float: right;
}
}
.volume-information {
margin-top: -2.5em;
width: 80%;
background: #fdecd1;
border-radius: 10px;
}
.vertical-line {
position: relative;
top: -25px;
left: 1.5rem;
border: 2px dotted #ccc;
width: 20px;
min-height: 35px;
border-color: transparent transparent #f3a22d #f3a22d;
border-bottom-left-radius: 10px;
}
}
}
// Library grid
.my-masonry-grid {
display: -webkit-box; /* Not needed if autoprefixing */
display: -ms-flexbox; /* Not needed if autoprefixing */
display: flex;
margin-left: -30px; /* gutter size offset */
width: auto;
}
.my-masonry-grid_column {
padding-left: 30px; /* gutter size */
background-clip: padding-box;
}
.my-masonry-grid_column > div {
/* change div to reference your elements you put in <Masonry> */
margin-bottom: 20px;
}
// progress
.progress-indicator-container {
height: 100%;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
.indicator {
padding: 5px;
width: 120px;
height: 120px;
}
}

View File

@@ -1,20 +1,13 @@
import React, { ReactElement, useEffect } from "react"; import React, { ReactElement } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Navbar2 } from "./shared/Navbar2"; import Navbar from "./shared/Navbar";
import { ToastContainer } from "react-toastify"; import "../assets/scss/App.scss";
import "../assets/scss/App.css";
import { useStore } from "../store";
export const App = (): ReactElement => { export const App = (): ReactElement => {
useEffect(() => {
useStore.getState().getSocket("/"); // Connect to the base namespace
}, []);
return ( return (
<> <>
<Navbar2 /> <Navbar />
<Outlet /> <Outlet />
<ToastContainer stacked hideProgressBar />
</> </>
); );
}; };

View File

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

View File

@@ -1,23 +1,94 @@
import React, { ReactElement } from "react"; import { filter, isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select"; import React, { ReactElement, useCallback } from "react";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => { export const Menu = (props): ReactElement => {
const { const { data } = props;
filteredActionOptions, const { setSlidingPanelContentId, setVisible } = props.handlers;
customStyles, const openDrawerWithCVMatches = useCallback(() => {
handleActionSelection, let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
Placeholder, let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
} = props.configuration;
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);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
option: (base, { data, isDisabled, isFocused, isSelected }) => {
return {
...base,
backgroundColor: isFocused ? "gray" : "black",
};
},
control: (base) => ({
...base,
backgroundColor: "black",
border: "1px solid #CCC",
}),
};
return ( return (
<Select <Select
components={{ Placeholder }} components={{ Placeholder }}
placeholder={ placeholder={
<span className="inline-flex flex-row items-center gap-2 pt-1"> <span>
<div className="w-6 h-6"> <i className="fa-solid fa-list"></i> Actions
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
</div>
<div>Select An Action</div>
</span> </span>
} }
styles={customStyles} styles={customStyles}

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

View File

@@ -1,71 +1,34 @@
import React, { useState, ReactElement, useCallback } from "react"; import React, { useState, ReactElement, useCallback } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { RawFileDetails } from "./RawFileDetails"; import { RawFileDetails } from "./RawFileDetails";
import { ComicVineSearchForm } from "./ComicVineSearchForm";
import TabControls from "./TabControls"; import TabControls from "./TabControls";
import { EditMetadataPanel } from "./EditMetadataPanel";
import { Menu } from "./ActionMenu/Menu"; import { Menu } from "./ActionMenu/Menu";
import { isEmpty, isUndefined, isNil, filter } from "lodash"; import { ArchiveOperations } from "./Tabs/ArchiveOperations";
import { components } from "react-select"; 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-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 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 { 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 = {};
/** /**
* Component for displaying the metadata for a comic in greater detail. * Component for displaying the metadata for a comic in greater detail.
* *
@@ -75,6 +38,7 @@ type ComicDetailProps = {
* <ComicDetail/> * <ComicDetail/>
* ) * )
*/ */
export const ComicDetail = (data: ComicDetailProps): ReactElement => { export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const { const {
data: { data: {
@@ -83,75 +47,110 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
inferredMetadata, inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo }, sourcedMetadata: { comicvine, locg, comicInfo },
acquisition, acquisition,
createdAt,
updatedAt,
}, },
userSettings, userSettings,
queryClient,
comicObjectId: comicObjectIdProp,
} = data; } = data;
const [page, setPage] = useState(1);
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false); const [modalIsOpen, setIsOpen] = useState(false);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Modal handlers (currently unused but kept for future use) // const dispatch = useDispatch();
const openModal = useCallback((filePath: string) => {
const openModal = useCallback((filePath) => {
setIsOpen(true); setIsOpen(true);
// dispatch(
// extractComicArchive(filePath, {
// type: "full",
// purpose: "reading",
// imageResizeOptions: {
// baseWidth: 1024,
// },
// }),
// );
}, []); }, []);
const afterOpenModal = useCallback((things: any) => { const afterOpenModal = useCallback((things) => {
// Modal opened callback // references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
console.log("kolaveri", things);
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
setIsOpen(false); setIsOpen(false);
}, []); }, []);
// Action event handlers // sliding panel init
const openDrawerWithCVMatches = () => { const contentForSlidingPanel = {
prepareAndFetchMatches(rawFileDetails, comicvine); CVMatches: {
setSlidingPanelContentId("CVMatches"); content: (props) => (
setVisible(true); <>
{/* <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 />,
},
}; };
const openEditMetadataPanel = useCallback(() => { // check for the availability of CV metadata
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// 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 handleActionSelection = (action: ActionOption) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
break;
}
};
// Check for metadata availability
const isComicBookMetadataAvailable = const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation); !isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable = const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails); !isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
const { issueName, url } = determineCoverFile({ const { issueName, url } = determineCoverFile({
rawFileDetails, rawFileDetails,
@@ -159,111 +158,172 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
locg, locg,
}); });
// Query for airdc++ // query for airdc++
const airDCPPQuery = { const airDCPPQuery = {
issue: { issue: {
name: issueName, name: issueName,
}, },
}; };
// Create tab configuration // Tab content and header details
const tabGroup = createTabConfig({ const tabGroup = [
data: data.data, {
comicInfo, id: 1,
isComicBookMetadataAvailable, name: "Volume Information",
areRawFileDetailsAvailable, icon: <i className="fa-solid fa-layer-group"></i>,
airDCPPQuery, content: isComicBookMetadataAvailable ? (
comicObjectId: _id, <VolumeInformation data={data.data} key={1} />
userSettings, ) : null,
issueName, shouldShow: isComicBookMetadataAvailable,
acquisition, },
}); {
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-circle-nodes"></i>,
name: "DC++ Search",
content: (
<AcquisitionPanel
query={airDCPPQuery}
comicObjectId={_id}
comicObject={data.data}
userSettings={userSettings}
key={4}
/>
),
shouldShow: true,
},
{
id: 5,
icon: <i className="fa-solid fa-droplet"></i>,
name: "Torrent Search",
content: <>Torrents</>,
shouldShow: true,
},
{
id: 6,
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); const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
// Sliding panel content mapping // Determine which cover image to use:
const renderSlidingPanelContent = () => { // 1. from the locally imported or
switch (slidingPanelContentId) { // 2. from the CV-scraped version
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;
}
};
return ( 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"> <div className="section">
{!isNil(data) && !isEmpty(data) && ( {!isNil(data) && !isEmpty(data) && (
<> <>
<div> <h1 className="title">{issueName}</h1>
<div className="flex flex-row mt-5"> <div className="columns is-multiline">
<div className="column is-narrow">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation={"vertical"}
hasDetails={false} hasDetails={false}
cardContainerStyle={{ maxWidth: 275 }}
/> />
{/* action dropdown */}
{/* raw file details */} <div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div>
</div>
{/* raw file details */}
<div className="column">
{!isUndefined(rawFileDetails) && {!isUndefined(rawFileDetails) &&
!isEmpty(rawFileDetails?.cover) && ( !isEmpty(rawFileDetails.cover) && (
<div className="grid"> <>
<RawFileDetails <RawFileDetails
data={{ data={{
rawFileDetails: rawFileDetails, rawFileDetails: rawFileDetails,
inferredMetadata: inferredMetadata, inferredMetadata: inferredMetadata,
created_at: createdAt,
updated_at: updatedAt,
}} }}
/>
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => openModal(rawFileDetails.filePath)}
> >
{/* action dropdown */} <i className="fa-solid fa-book-open mr-2"></i>
<div className="mt-1 flex flex-row gap-2 w-full"> Read
<Menu </button>
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }} {/* <Modal
configuration={{ style={{ content: { marginTop: "2rem" } }}
filteredActionOptions, isOpen={modalIsOpen}
customStyles, onAfterOpen={afterOpenModal}
handleActionSelection, onRequestClose={closeModal}
Placeholder, contentLabel="Example Modal"
>
<button onClick={closeModal}>close</button>
{extractedComicBook && (
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{
closeButton: "border: 1px solid red;",
}} }}
/> />
</div> )}
</RawFileDetails> </Modal> */}
</div> </>
)} )}
</div> </div>
</div> </div>
<TabControls <TabControls
filteredTabs={filteredTabs} filteredTabs={filteredTabs}
downloadCount={acquisition?.directconnect?.downloads?.length || 0} acquisition={acquisition}
activeTab={activeTab}
setActiveTab={setActiveTab}
/> />
<StyledSlidingPanel <SlidingPane
isOpen={visible} isOpen={visible}
onRequestClose={() => setVisible(false)} onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"} title={"Comic Vine Search Matches"}
width={"600px"} width={"600px"}
> >
{renderSlidingPanelContent()} {slidingPanelContentId !== "" &&
</StyledSlidingPanel> contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</> </>
)} )}
</div> </div>

View File

@@ -1,40 +1,42 @@
import React, { ReactElement } from "react"; import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail"; import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useGetComicByIdQuery } from "../../graphql/generated"; import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter"; import axios from "axios";
export const ComicDetailContainer = (): ReactElement | null => { export const ComicDetailContainer = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const queryClient = useQueryClient();
const { const {
data: comicBookDetailData, data: comicBookDetailData,
isLoading, isLoading,
isError, isError,
} = useGetComicByIdQuery( } = useQuery({
{ id: comicObjectId! }, queryKey: [],
{ enabled: !!comicObjectId } queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
); );
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}
/>
) : null;
}; };

View File

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

View File

@@ -2,43 +2,22 @@ import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm"; import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => { export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props; const {
const { comicvine } = useStore( comicObjectId,
useShallow((state) => ({ comicVineSearchQueryObject,
comicvine: state.comicvine, comicVineAPICallProgress,
})), comicVineSearchResults,
); } = comicVineData.props;
return ( return (
<> <>
<div> <div className="search-results-container">
{!isEmpty(comicVineMatches) ? ( {!isEmpty(comicVineSearchResults) && (
<MatchResult <MatchResult
matchData={comicVineMatches} matchData={comicVineSearchResults}
comicObjectId={comicObjectId} comicObjectId={comicObjectId}
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> </div>
</> </>

View File

@@ -34,55 +34,83 @@ export const ComicVineSearchForm = (data) => {
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<span className="flex items-center"> <span className="field is-normal">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5"> <label className="label mb-2 is-size-5">Search Manually</label>
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span> </span>
<label className="block py-1">Issue Name</label> <div className="field is-horizontal">
<Field name="issueName"> <div className="field-body">
{(props) => ( <div className="field">
<input <Field name="issueName">
{...props.input} {(props) => (
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" <p className="control is-expanded has-icons-left">
placeholder="Type the issue name" <input
/> {...props.input}
)} className="input is-normal"
</Field> placeholder="Type the issue name"
<div className="flex flex-row gap-4"> />
<div> <span className="icon is-small is-left">
<label className="block py-1">Number</label> <i className="fas fa-journal-whills"></i>
<Field name="issueNumber"> </span>
{(props) => ( </p>
<input )}
{...props.input} </Field>
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" </div>
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>
</div>
<div className="flex justify-end mt-5"> <div className="field is-horizontal">
<button <div className="field-body">
type="submit" <div className="field">
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" <Field name="issueNumber">
> {(props) => (
Search <p className="control has-icons-left">
</button> <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>
</div> </div>
</form> </form>

View File

@@ -1,127 +1,32 @@
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import React, { ReactElement, useEffect, useRef, useState } from "react"; import React, { ReactElement } 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);
export const DownloadProgressTick = (props): ReactElement => {
return ( return (
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm"> <div>
{/* Downloaded vs Total */} <h4 className="is-size-5">{props.data.name}</h4>
<div className="mt-1 flex items-center space-x-2"> <div>
<span className="text-sm text-gray-700">{downloaded} of {total}</span> <span className="is-size-4 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>
<div className="is-size-6 mt-1 mb-2">
<p>{prettyBytes(props.data.speed)} per second.</p>
Time left:
{Math.round(parseInt(props.data.seconds_left) / 60)}
</div> </div>
{/* Progress bar */} <div>{props.data.target}</div>
<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>
<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>
</div> </div>
); );
}; };

View File

@@ -1,154 +1,103 @@
import React, { useEffect, ReactElement, useState, useMemo } from "react"; import React, { useEffect, useContext, ReactElement } from "react";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { AirDCPPBundles } from "./AirDCPPBundles"; import { RootState } from "threetwo-ui-typings";
import { TorrentDownloads } from "./TorrentDownloads"; import { isEmpty, isNil, map } from "lodash";
import { useQuery } from "@tanstack/react-query"; import prettyBytes from "pretty-bytes";
import axios from "axios"; import dayjs from "dayjs";
import { import ellipsize from "ellipsize";
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";
export interface TorrentDetails { interface IDownloadsPanelProps {
infoHash: string; data: any;
progress: number; comicObjectId: string;
downloadSpeed?: number;
uploadSpeed?: number;
} }
/** export const DownloadsPanel = (
* DownloadsPanel displays two tabs of download information for a specific comic: props: IDownloadsPanelProps,
* - DC++ (AirDCPP) bundles ): ReactElement | null => {
* - Torrent downloads // const bundles = useSelector((state: RootState) => {
* It also listens for real-time torrent updates via a WebSocket. // return state.airdcpp.bundles;
* // });
* @component //
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available. // // AirDCPP Socket initialization
*/ // const userSettings = useSelector((state: RootState) => state.settings.data);
export const DownloadsPanel = (): ReactElement | null => { // const airDCPPConfiguration = useContext(AirDCPPSocketContext);
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( const {
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })), airDCPPState: { socket, settings },
); } = airDCPPConfiguration;
/** // Fetch the downloaded files and currently-downloading file(s) from AirDC++
* Registers socket listeners on mount and cleans up on unmount.
*/
useEffect(() => { useEffect(() => {
if (!socketIOInstance) return; 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);
}
}, [airDCPPConfiguration]);
/** const Bundles = (props) => {
* Handler for incoming torrent data events. return !isEmpty(props.data) ? (
* Merges new entries or updates existing ones by infoHash. <div className="column is-full">
* <table className="table is-striped">
* @param {TorrentDetails} data - Payload from the socket event. <thead>
*/ <tr>
const handleTorrentData = (data: TorrentDetails) => { <th>Filename</th>
setTorrentDetails((prev) => { <th>Size</th>
const idx = prev.findIndex((t) => t.infoHash === data.infoHash); <th>Download Time</th>
if (idx === -1) { <th>Bundle ID</th>
return [...prev, data]; </tr>
} </thead>
const next = [...prev]; <tbody>
next[idx] = { ...next[idx], ...data }; {map(props.data, (bundle) => (
return next; <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>
);
};
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData); return !isNil(props.data) ? (
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`,
},
},
}),
});
// ————— 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",
});
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
useEffect(() => {
if (activeTab !== "torrents") return;
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
}, [activeTab]);
return (
<> <>
<div className="mt-5 mb-3"> <div className="columns is-multiline">
<nav className="flex space-x-2"> {!isEmpty(socket) ? (
<button <Bundles data={bundles} />
onClick={() => setActiveTab("directconnect")} ) : (
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${ <div className="column is-three-fifths">
activeTab === "directconnect" <article className="message is-info">
? "bg-green-500 text-white" <div className="message-body is-size-6 is-family-secondary">
: "bg-gray-200 text-gray-700 hover:bg-gray-300" AirDC++ is not configured. Please configure it in{" "}
}`} <code>Settings</code>.
> </div>
DC++ </article>
</button> </div>
<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> </div>
</> </>
); ) : null;
}; };
export default DownloadsPanel; export default DownloadsPanel;

View File

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

View File

@@ -1,15 +1,12 @@
import React from "react"; import React, { useCallback } from "react";
import { isNil, map } from "lodash"; import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
interface MatchResultProps { interface MatchResultProps {
matchData: any; matchData: any;
comicObjectId: string; comicObjectId: string;
queryClient?: any;
onMatchApplied?: () => void;
} }
const handleBrokenImage = (e) => { const handleBrokenImage = (e) => {
@@ -17,43 +14,14 @@ const handleBrokenImage = (e) => {
}; };
export const MatchResult = (props: MatchResultProps) => { export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = async (match, comicObjectId) => { const applyCVMatch = useCallback(
try { // (match, comicObjectId) => {
const response = await axios.request({ // dispatch(applyComicVineMatch(match, comicObjectId));
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 ( 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) => { {map(props.matchData, (match, idx) => {
let issueDescription = ""; let issueDescription = "";
if (!isNil(match.description)) { if (!isNil(match.description)) {
@@ -63,90 +31,88 @@ export const MatchResult = (props: MatchResultProps) => {
}, },
}); });
} }
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return ( return (
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}> <div className="search-result mb-4" key={idx}>
<div className="flex flex-row gap-4"> <div className="columns">
<div className="min-w-fit"> <div className="column is-one-fifth">
<img <img
className="rounded-md" className="cover-image"
src={match.image.thumb_url} src={match.image.thumb_url}
onError={handleBrokenImage} onError={handleBrokenImage}
/> />
</div> </div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
{/* score */} <div className="search-result-details column">
<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"> <div className="is-size-5">{match.name}</div>
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i> <div className="field is-grouped is-grouped-multiline mt-1">
</span> <div className="control">
<span className="text-slate-900 dark:text-slate-900"> <div className="tags has-addons">
{parseInt(match.score, 10)} <span className="tag">Number</span>
</span> <span className="tag is-primary">
</span> {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>
<span className="flex flex-row gap-2 mb-2"> <div className="is-size-7">
<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)} {ellipsize(issueDescription, 300)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-row gap-2 my-4 ml-10"> <div className="vertical-line"></div>
<div className="">
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<img <img
src={match.volumeInformation.results.image.icon_url} src={match.volumeInformation.results.image.icon_url}
className="rounded-md w-full" className="cover-image"
onError={handleBrokenImage} onError={handleBrokenImage}
/> />
</div> </div>
<div className=""> <div className="column">
<span>{match.volume.name}</span> <div className="is-size-6">{match.volume.name}</div>
<div className="text-sm"> <div className="field is-grouped is-grouped-multiline mt-2">
<p> <div className="control">
Total Issues: <div className="tags has-addons">
{match.volumeInformation.results.count_of_issues} <span className="tag">Total Issues</span>
</p> <span className="tag is-warning">
<p> {match.volumeInformation.results.count_of_issues}
Published by{" "} </span>
{match.volumeInformation.results.publisher.name} </div>
</p> </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>
</div> </div>
<div className="flex justify-end"> <div className="columns">
<button <div className="column">
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" <button
onClick={() => applyCVMatch(match, props.comicObjectId)} className="button is-normal is-outlined is-primary is-light is-pulled-right"
> onClick={() => applyCVMatch(match, props.comicObjectId)}
<span className="text-md">Apply Match</span> >
<span className="w-5 h-5"> <span className="icon is-size-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i> <i className="fas fa-clipboard-check"></i>
</span> </span>
</button> <span>Apply Match</span>
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,121 +1,106 @@
import React, { ReactElement, ReactNode } from "react"; import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
type RawFileDetailsProps = { export const RawFileDetails = (props): ReactElement => {
data?: { const { rawFileDetails, inferredMetadata } = props.data;
rawFileDetails?: RawFileDetailsType;
inferredMetadata?: {
issue?: {
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
};
children?: ReactNode;
};
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data || {};
return ( return (
<> <>
<div className="max-w-2xl ml-5"> <div className="comic-detail raw-file-details column is-three-quarters">
<div className="px-4 sm:px-6"> <dl>
<p className="text-gray-500 dark:text-gray-400"> <dt>Raw File Details</dt>
<span className="text-xl">{rawFileDetails?.name}</span> <dd className="is-size-7">
</p> {rawFileDetails.containedIn +
</div> "/" +
<div className="px-4 py-5 sm:px-6"> rawFileDetails.name +
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2"> rawFileDetails.extension}
<div className="sm:col-span-1"> </dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dd>
Raw File Details <div className="field is-grouped mt-2">
</dt> <div className="control">
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400"> <div className="tags has-addons">
{rawFileDetails?.containedIn} <span className="tag">Size</span>
{"/"} <span className="tag is-info is-light">
{rawFileDetails?.name} {prettyBytes(rawFileDetails.fileSize)}
{rawFileDetails?.extension} </span>
</dd> </div>
</div> </div>
<div className="sm:col-span-1"> <div className="control">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <div className="tags has-addons">
Inferred Issue Metadata <span className="tag">Extension</span>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
Series Name: {inferredMetadata?.issue?.name}
{!isEmpty(inferredMetadata?.issue?.number) ? (
<span className="tag is-primary is-light"> <span className="tag is-primary is-light">
{inferredMetadata?.issue?.number} {rawFileDetails.extension}
</span> </span>
) : null} </div>
</dd> </div>
<div className="control">
<div className="tags has-addons">
<span className="tag">MIME type</span>
<span className="tag is-primary is-light">
{rawFileDetails.mimeType}
</span>
</div>
</div>
</div> </div>
<div className="sm:col-span-1"> </dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> </dl>
MIMEType </div>
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pt-1">
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900"> <div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
{rawFileDetails?.mimeType} <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>
</span> </div>
</dd> </div>
{!isEmpty(inferredMetadata.issue.number) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary is-light">
{inferredMetadata.issue.number}
</span>
</div>
</div>
) : null}
</div> </div>
<div className="sm:col-span-1"> </dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> </dl>
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">
{created_at ? (
<>
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "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>
</div> </div>
</> </>
); );
}; };
export default RawFileDetails; export default RawFileDetails;
RawFileDetails.propTypes = {
data: PropTypes.shape({
rawFileDetails: PropTypes.shape({
containedIn: PropTypes.string,
name: PropTypes.string,
fileSize: PropTypes.number,
path: PropTypes.string,
extension: PropTypes.string,
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,48 @@
import React, { ReactElement, Suspense, useState } from "react"; import React, { ReactElement, useEffect, useState } from "react";
import { isNil } from "lodash"; import { isEmpty, isNil } from "lodash";
export const TabControls = (props): ReactElement => { export const TabControls = (props): ReactElement => {
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props; // const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, acquisition } = props;
const [active, setActive] = useState(filteredTabs[0].id); const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
// Use controlled state if provided, otherwise use internal state setActive(filteredTabs[0].id);
const currentActive = activeTab !== undefined ? activeTab : active; }, [acquisition]);
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
console.log(filteredTabs);
return ( return (
<> <>
<div className="hidden sm:block mt-7 mb-3 w-fit"> <div className="tabs">
<div className="border-b border-gray-200"> <ul>
<nav className="flex gap-6" aria-label="Tabs"> {filteredTabs.map(({ id, name, icon }) => (
{filteredTabs.map(({ id, name, icon }) => ( <li
<a key={id}
key={id} className={id === active ? "is-active" : ""}
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${ onClick={() => setActive(id)}
currentActive === id >
? "border-b border-cyan-50 dark:text-slate-200" {/* Downloads tab and count badge */}
: "border-b border-transparent" <a>
}`} {id === 6 && !isNil(acquisition.directconnect) ? (
aria-current="page" <span className="download-icon-labels">
onClick={() => handleSetActive(id)} <i className="fa-solid fa-download"></i>
> <span className="tag downloads-count is-info is-light">
{/* Downloads tab and count badge */} {acquisition.directconnect.downloads.length}
<>
{id === 6 && !isNil(downloadCount) ? (
<span className="inline-flex flex-row">
{/* download count */}
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
<span className="text-md text-slate-500 dark:text-slate-900">
{icon}
</span>
</span>
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
</span> </span>
) : ( </span>
<span className="w-5 h-5">{icon}</span> ) : (
)} <span className="icon is-small">{icon}</span>
{name} )}
</> {name}
</a> </a>
))} </li>
</nav> ))}
</div> </ul>
</div> </div>
<Suspense> {filteredTabs.map(({ id, content }) => {
{filteredTabs.map(({ id, content }) => { return active === id ? content : null;
return currentActive === id ? content : null; })}
})}
</Suspense>
</> </>
); );
}; };

View File

@@ -1,155 +1,57 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement, useCallback, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD"; import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane"; import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas"; import { Canvas } from "../../shared/Canvas";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import {
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../../constants/endpoints";
import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow";
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
export const ArchiveOperations = (props: { data: any }): ReactElement => { export const ArchiveOperations = (props): ReactElement => {
const { data } = props; 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 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 // sliding panel config
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image // current image
const [currentImage, setCurrentImage] = useState<string>(""); const [currentImage, setCurrentImage] = useState([]);
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);
}
// sliding panel init // sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = { const contentForSlidingPanel = {
imageAnalysis: { imageAnalysis: {
content: () => { content: () => {
return ( return (
<div> <div>
<pre className="text-sm">{currentImage}</pre> <pre className="is-size-7">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? ( {!isEmpty(imageAnalysisResult) ? (
<pre className="p-2 mt-3"> <pre className="is-size-7 p-2 mt-3">
<Canvas data={imageAnalysisResult} /> <Canvas data={imageAnalysisResult} />
</pre> </pre>
) : null} ) : null}
<pre className="font-hasklig mt-3 text-sm"> <pre className="is-size-7 mt-3">
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)} {JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre> </pre>
</div> </div>
); );
@@ -158,83 +60,56 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
}; };
// sliding panel handlers // sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath: string) => { const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis"); setSlidingPanelContentId("imageAnalysis");
analyzeImage(imageFilePath); // dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath); setCurrentImage(imageFilePath);
setVisible(true); setVisible(true);
}, []); }, []);
return ( return (
<div key={2}> <div key={2}>
<article <button
role="alert" className={
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600" isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
> >
<div> <span className="icon is-small">
<p>You can perform several operations on your comic book archive.</p> <i className="fa-solid fa-box-open"></i>
<p> </span>
Uncompressing, re-organizing the individual pages, then <span>Unpack comic archive</span>
re-compressing to a different format, for example. </button>
</p> <div className="columns">
<p>You can also analyze color histograms of pages.</p> <div className="mt-5">
</div> {!isEmpty(extractedComicBookArchive) ? (
</article>
<div className="mt-5">
{data.rawFileDetails.archive?.uncompressed &&
!isEmpty(uncompressedArchive) ? (
<article
role="alert"
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
This issue is already uncompressed at:
<p>
<code className="font-hasklig text-sm">
{data.rawFileDetails.archive.expandedPath}
</code>
<div className="">It has {uncompressedArchive?.length} pages</div>
</p>
</article>
) : null}
<div className="flex flex-row gap-2 mt-4">
{isEmpty(uncompressedArchive) ? (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Unpack Comic Archive</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
</span>
</button>
) : null}
{!isEmpty(uncompressedArchive) ? (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Convert to .cbz</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
</span>
</button>
</div>
) : null}
</div>
</div>
<div>
<div className="mt-10">
{!isEmpty(uncompressedArchive) ? (
<DnD <DnD
data={uncompressedArchive} data={extractedComicBookArchive}
onClickHandler={openImageAnalysisPanel} onClickHandler={openImageAnalysisPanel}
/> />
) : null} ) : null}
</div> </div>
{!isEmpty(extractedComicBookArchive) ? (
<div className="column mt-5">
<Sticky enabled={true} top={70} bottomBoundary={3000}>
<div className="card">
<div className="card-content">
<span className="has-text-size-4">
{extractedComicBookArchive.length} pages
</span>
<button className="button is-small is-light is-primary is-outlined">
<span className="icon is-small">
<i className="fa-solid fa-compress"></i>
</span>
<span>Convert to CBZ</span>
</button>
</div>
</div>
</Sticky>
</div>
) : null}
</div> </div>
<SlidingPane <SlidingPane
isOpen={visible} isOpen={visible}

View File

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

View File

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

View File

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

@@ -1,72 +1,66 @@
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 { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList"; import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups"; import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics"; import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList"; import { PullList } from "./PullList";
import { import {
useGetRecentComicsQuery, fetchVolumeGroups,
useGetWantedComicsQuery, getComicBooks,
useGetVolumeGroupsQuery, } from "../../actions/fileops.actions";
useGetLibraryStatisticsQuery import { getLibraryStatistics } from "../../actions/comicinfo.actions";
} from "../../graphql/generated"; import { isEmpty, isNil } from "lodash";
import Header from "../shared/Header";
export const Dashboard = (): ReactElement => { export const Dashboard = (): ReactElement => {
// Use GraphQL for recent comics // useEffect(() => {
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery( // dispatch(fetchVolumeGroups());
{ limit: 5 }, // dispatch(
{ refetchOnWindowFocus: false } // getComicBooks({
); // paginationOptions: {
// page: 0,
// Wanted Comics - using GraphQL // limit: 5,
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery( // sort: { updatedAt: "-1" },
{ // },
paginationOptions: { // predicate: { "acquisition.source.wanted": false },
page: 1, // comicStatus: "recent",
limit: 5, // }),
sort: '{"updatedAt": -1}' // );
}, // dispatch(
predicate: '{"acquisition.source.wanted": true}' // getComicBooks({
}, // paginationOptions: {
{ // page: 0,
refetchOnWindowFocus: false, // limit: 5,
retry: false // sort: { updatedAt: "-1" },
} // },
); // predicate: { "acquisition.source.wanted": true },
// comicStatus: "wanted",
// Volume Groups - using GraphQL // }),
const { data: volumeGroupsData, error: volumeGroupsError } = useGetVolumeGroupsQuery( // );
undefined, // dispatch(getLibraryStatistics());
{ refetchOnWindowFocus: false } // }, []);
); //
// const recentComics = useSelector(
// Library Statistics - using GraphQL // (state: RootState) => state.fileOps.recentComics,
const { data: statisticsData, error: statisticsError } = useGetLibraryStatisticsQuery( // );
undefined, // const wantedComics = useSelector(
{ // (state: RootState) => state.fileOps.wantedComics,
refetchOnWindowFocus: false, // );
retry: false // const volumeGroups = useSelector(
} // (state: RootState) => state.fileOps.comicVolumeGroups,
); // );
//
const recentComics = recentComicsData?.comics?.comics || []; // const libraryStatistics = useSelector(
const wantedComics = !wantedComicsError ? (wantedComicsData?.getComicBooks?.docs || []) : []; // (state: RootState) => state.comicInfo.libraryStatistics,
const volumeGroups = volumeGroupsData?.getComicBookGroups || []; // );
const statistics = !statisticsError ? statisticsData?.getLibraryStatistics : undefined;
return ( return (
<> <div className="container">
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <section className="section">
<PullList /> <h1 className="title">Dashboard</h1>
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />} </section>
{/* Wanted comics */} </div>
<WantedComicsList comics={wantedComics} />
{/* Library Statistics */}
{statistics && <LibraryStatistics stats={statistics} />}
{/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups} />
</div>
</>
); );
}; };

View File

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

View File

@@ -1,180 +1,160 @@
import React, { ReactElement, useState } from "react"; import { isNil, map } from "lodash";
import { map } from "lodash"; import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import Header from "../shared/Header"; import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom";
import axios from "axios"; type PullListProps = {
import { useMutation, useQueryClient } from "@tanstack/react-query"; issues: any;
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";
interface PullListProps { export const PullList = ({ issues }: PullListProps): ReactElement => {
issues?: LocgMetadata[]; const dispatch = useDispatch();
} useEffect(() => {
dispatch(
export const PullList = (): ReactElement => { getWeeklyPullList({
const queryClient = useQueryClient(); startDate: "2023-9-9",
pageSize: "15",
// datepicker currentPage: "1",
const date = new Date(); }),
const [inputValue, setInputValue] = useState<string>( );
format(date, "yyyy/M/dd"), }, []);
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
); );
/*
// embla carousel const foo = {
const [emblaRef, emblaApi] = useEmblaCarousel({ coverFile: {}, // pointer to which cover file to use
loop: false, rawFileDetails: {}, // #1
align: "start", sourcedMetadata: {
containScroll: "trimSnaps", comicInfo: {},
slidesToScroll: 1, comicvine: {}, // #2
}); locg: {}, // #2
const {
data: pullListData,
refetch,
isSuccess,
isLoading,
isError,
} = useGetWeeklyPullListQuery({
input: {
startDate: inputValue,
pageSize: 15,
currentPage: 1,
}, },
}); };
*/
// Transform the data to match the old structure const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined; let sliderRef = createRef();
const settings = {
const { mutate: addToLibrary } = useMutation({ dots: false,
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => { infinite: false,
const comicBookMetadata = { speed: 500,
importType: "new", slidesToShow: 5,
payload: { slidesToScroll: 5,
rawFileDetails: { initialSlide: 0,
name: "", responsive: [
}, {
importStatus: { breakpoint: 1024,
isImported: true, settings: {
tagged: false, slidesToShow: 3,
matchedResult: { slidesToScroll: 3,
score: "0", infinite: false,
},
},
sourcedMetadata: metadata || null,
acquisition: { source: { wanted: true, name: sourceName } },
}, },
}; },
{
return await axios.request({ breakpoint: 600,
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`, settings: {
method: "POST", slidesToShow: 2,
data: comicBookMetadata, slidesToScroll: 2,
}); initialSlide: 0,
}, },
onSuccess: () => { },
// Invalidate and refetch wanted comics queries {
queryClient.invalidateQueries({ queryKey: ["wantedComics"] }); breakpoint: 480,
}, settings: {
}); slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
const next = () => { const next = () => {
// sliderRef.slickNext(); sliderRef.slickNext();
}; };
const previous = () => { const previous = () => {
// sliderRef.slickPrev(); sliderRef.slickPrev();
}; };
return ( return (
<> <>
<Header <div className="content">
headerContent="Discover" <Header headerContent="Discover"
subHeaderContent={ subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
<span className="text-md"> iconClassNames="fa-solid fa-binoculars mr-2"/>
Pull List aggregated for the week from{" "} <div className="field is-grouped">
<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">
{/* select week */} {/* select week */}
<div className="flex flex-row gap-4 my-3"> <div className="control">
<Form <div className="select is-small">
onSubmit={() => {}} <select>
render={({ handleSubmit }) => ( <option>Select Week</option>
<form> <option>With options</option>
<div className="flex flex-col gap-2"> </select>
{/* week selection for pull list */}
<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> </div>
</div> </div>
)} {/* See all pull list issues */}
{isLoading && <div>Loading...</div>} <div className="control">
{isError && <div>An error occurred while retrieving the pull list.</div>} <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> </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

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

View File

@@ -2,81 +2,62 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import Card from "../shared/Carda"; import Masonry from "react-masonry-css";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetVolumeGroupsQuery } from "../../graphql/generated";
type VolumeGroupsProps = { export const VolumeGroups = (props): ReactElement => {
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups']; const breakpointColumnsObj = {
}; default: 5,
1100: 4,
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => { 700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId // Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id"); const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToVolumes = (row: any) => { const navigateToVolumes = (row) => {
navigate(`/volumes/all`); navigate(`/volumes/all`);
}; };
// embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return ( return (
<div> <section className="volumes-container mt-4">
<Header <div className="content">
headerContent="Volumes" <a className="mb-1" onClick={navigateToVolumes}>
subHeaderContent={<>Based on ComicVine Volume information</>} <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-layer-group"></i> Volumes
link={"/volumes"} </span>
/> <span className="icon mt-1">
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3"> <i className="fa-solid fa-angle-right"></i>
<div className="overflow-hidden" ref={emblaRef}> </span>
<div className="flex"> </a>
{map(deduplicatedGroups, (data) => { <p className="subtitle is-7">Based on ComicVine Volume information</p>
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>
</div> </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

@@ -4,133 +4,111 @@ import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash"; import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
import useEmblaCarousel from "embla-carousel-react";
import { GetWantedComicsQuery } from "../../graphql/generated";
type WantedComicsListProps = { type WantedComicsListProps = {
comics?: GetWantedComicsQuery['getComicBooks']['docs']; comics: any;
}; };
export const WantedComicsList = ({ export const WantedComicsList = ({
comics, comics,
}: WantedComicsListProps): ReactElement => { }: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate(); const navigate = useNavigate();
const navigateToWantedComics = (row) => {
// embla carousel navigate(`/wanted/all`);
const [emblaRef, emblaApi] = useEmblaCarousel({ };
loop: false,
align: "start",
containScroll: "trimSnaps",
slidesToScroll: 1,
});
return ( return (
<div> <>
<Header <div className="content mt-6">
headerContent="Wanted Comics" <a className="mb-1" onClick={navigateToWantedComics}>
subHeaderContent={<>Comics marked as wanted from various sources</>} <span className="is-size-4 has-text-weight-semibold">
iconClassNames="fa-solid fa-binoculars mr-2" <i className="fa-solid fa-asterisk"></i> Wanted Comics
link={"/wanted"} </span>
/> <span className="icon mt-1">
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3"> <i className="fa-solid fa-angle-right"></i>
<div className="overflow-hidden" ref={emblaRef}> </span>
<div className="flex"> </a>
{map( <p className="subtitle is-7">
comics, Comics marked as wanted from various sources.
(comic) => { </p>
const { </div>
id, <Masonry
rawFileDetails, breakpointCols={breakpointColumnsObj}
sourcedMetadata, className="recent-comics-container"
} = comic; 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 { issueName, url } = determineCoverFile(
const comicvine = typeof sourcedMetadata?.comicvine === 'string' consolidatedComicMetadata,
? JSON.parse(sourcedMetadata.comicvine) );
: sourcedMetadata?.comicvine; const titleElement = (
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string' <Link to={"/comic/details/" + _id}>
? JSON.parse(sourcedMetadata.comicInfo) {ellipsize(issueName, 20)}
: sourcedMetadata?.comicInfo; </Link>
const locg = sourcedMetadata?.locg; );
return (
const isComicBookMetadataAvailable = !isUndefined(comicvine); <Card
const consolidatedComicMetadata = { key={_id}
rawFileDetails, orientation={"vertical"}
comicvine, imageUrl={url}
comicInfo, hasDetails
locg, title={issueName ? titleElement : <span>No Name</span>}
}; >
<div className="content is-flex is-flex-direction-row">
const { {/* comicVine metadata presence */}
issueName, {isComicBookMetadataAvailable && (
url, <span className="icon custom-icon">
publisher = null, <img src="/src/client/assets/img/cvlogo.svg" />
} = determineCoverFile(consolidatedComicMetadata); </span>
const titleElement = ( )}
<Link to={"/comic/details/" + id}> {!isEmpty(locg) && (
{ellipsize(issueName, 20)} <span className="icon custom-icon">
<p>{publisher}</p> <img src="/src/client/assets/img/locglogo.svg" />
</Link> </span>
); )}
return ( {/* Issue type */}
<div {isComicBookMetadataAvailable &&
key={id} !isNil(
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]" detectIssueTypes(comicvine.volumeInformation.description),
> ) ? (
<Card <span className="tag is-warning">
orientation={"vertical-2"} {
imageUrl={url} detectIssueTypes(
hasDetails comicvine.volumeInformation.description,
title={issueName ? titleElement : <span>No Name</span>} ).displayName
cardState="wanted" }
> </span>
<div className="pb-1"> ) : null}
<div className="flex flex-row gap-2"> </div>
{/* Issue type */} </Card>
{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"> </Masonry>
<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>
); );
}; };

View File

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

View File

@@ -57,12 +57,13 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
}, [issueBundles]); }, [issueBundles]);
return !isNil(bundles) ? ( return !isNil(bundles) ? (
<div className="container mx-auto px-4 sm:px-6 lg:px-8"> <div className="container">
<section className="section"> <section className="section">
<h1 className="title">Downloads</h1> <h1 className="title">Downloads</h1>
<div className="columns"> <div className="columns">
<div className="column is-half"> <div className="column is-half">
{bundles.map((bundle, idx) => { {bundles.map((bundle, idx) => {
console.log(bundle);
return ( return (
<div key={idx}> <div key={idx}>
<MetadataPanel <MetadataPanel

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,249 +1,122 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement, useCallback, useEffect } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns"; import { format } from "date-fns";
import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import axios from "axios"; import axios from "axios";
import {
useGetJobResultStatisticsQuery,
useGetImportStatisticsQuery,
useStartIncrementalImportMutation
} from "../../graphql/generated";
import { RealTimeImportStats } from "./RealTimeImportStats";
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
interface ImportProps { interface IProps {
matches?: unknown;
fetchComicMetadata?: any;
path: string; path: string;
covers?: any;
} }
/** /**
* Import component for adding comics to the ThreeTwo library. * Component to facilitate the import of comics to the ThreeTwo library
* Provides preview statistics, smart import, and queue management. *
* @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: ImportProps): ReactElement => {
export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0); const { importJobQueue, socketIOInstance } = useStore(
const [importError, setImportError] = useState<string | null>(null);
const { importJobQueue, getSocket, disconnectSocket } = useStore(
useShallow((state) => ({ useShallow((state) => ({
importJobQueue: state.importJobQueue, importJobQueue: state.importJobQueue,
getSocket: state.getSocket, socketIOInstance: state.socketIOInstance,
disconnectSocket: state.disconnectSocket,
})), })),
); );
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ const sessionId = localStorage.getItem("sessionId");
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const { mutate: initiateImport } = useMutation({ const { mutate: initiateImport } = useMutation({
mutationFn: async () => { mutationFn: async () =>
const sessionId = localStorage.getItem("sessionId"); await axios.request({
return await axios.request({
url: `http://localhost:3000/api/library/newImport`, url: `http://localhost:3000/api/library/newImport`,
method: "POST", method: "POST",
data: { sessionId }, data: { sessionId },
}); }),
},
}); });
// Force re-import mutation - re-imports all files regardless of import status const { data, isError, isLoading } = useQuery({
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({ queryKey: ["allImportJobResults"],
mutationFn: async () => { queryFn: async () =>
const sessionId = localStorage.getItem("sessionId") || ""; await axios({
return await axios.request({ method: "GET",
url: `http://localhost:3000/api/library/forceReImport`, url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
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, isError, isLoading, refetch } = useGetJobResultStatisticsQuery(); // 1a. Act on each comic issue successfully imported/failed, as indicated
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
// Get import statistics to determine if Start Import button should be shown socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
const { data: importStats } = useGetImportStatisticsQuery( const { completedJobCount, importResult } = data;
{}, importJobQueue.setJobCount("successful", completedJobCount);
{ importJobQueue.setMostRecentImport(importResult.rawFileDetails.name);
refetchOnWindowFocus: false, });
refetchInterval: false, socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
} const { failedJobCount } = data;
); importJobQueue.setJobCount("failed", failedJobCount);
});
// Use custom hook for definitive import session status tracking // 1b. Clear the localStorage sessionId upon receiving the
// NO POLLING - relies on Socket.IO events only // LS_IMPORT_QUEUE_DRAINED event
const importSession = useImportSessionStatus(); socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
localStorage.removeItem("sessionId");
const hasActiveSession = importSession.isActive; importJobQueue.setStatus("drained");
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
// Determine if we should show the Start Import button });
const hasNewFiles = importStats?.getImportStatistics?.success &&
importStats.getImportStatistics.stats &&
importStats.getImportStatistics.stats.newFiles > 0;
useEffect(() => {
const socket = getSocket("/");
const handleQueueDrained = () => refetch();
const handleCoverExtracted = () => refetch();
const handleSessionStarted = () => {
importJobQueue.setStatus("running");
};
const handleSessionCompleted = () => {
refetch();
importJobQueue.setStatus("drained");
};
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
return () => {
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
};
}, [getSocket, refetch, importJobQueue, socketReconnectTrigger]);
/**
* Toggles import queue pause/resume state
*/
const toggleQueue = (queueAction: string, queueStatus: string) => { const toggleQueue = (queueAction: string, queueStatus: string) => {
const socket = getSocket("/"); socketIOInstance.emit(
socket.emit(
"call", "call",
"socket.setQueueStatus", "socket.setQueueStatus",
{ {
queueAction, queueAction,
queueStatus, queueStatus,
}, },
(data) => console.log(data),
); );
}; };
/** /**
* Starts smart import with race condition prevention * Method to render import job queue pause/resume controls on the UI
*/ *
const handleStartSmartImport = async () => { * @param status The `string` status (either `"pause"` or `"resume"`)
// Clear any previous errors * @returns ReactElement A `<button/>` that toggles queue status
setImportError(null); * @remarks Sets the global `importJobQueue.status` state upon toggling
// 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 (importJobQueue.status === "drained") {
localStorage.removeItem("sessionId");
disconnectSocket("/");
setTimeout(() => {
getSocket("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}, 500);
}, 100);
} else {
const sessionId = localStorage.getItem("sessionId") || "";
startIncrementalImport({ sessionId });
}
};
/**
* 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("/");
setSocketReconnectTrigger(prev => prev + 1);
setTimeout(() => {
forceReImport();
}, 500);
}, 100);
} else {
forceReImport();
}
}
};
/**
* Renders pause/resume controls based on queue status
*/ */
const renderQueueControls = (status: string): ReactElement | null => { const renderQueueControls = (status: string): ReactElement | null => {
switch (status) { switch (status) {
case "running": case "running":
return ( return (
<div> <div className="control">
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" className="button is-warning is-light"
onClick={() => { onClick={() => {
toggleQueue("pause", "paused"); toggleQueue("pause", "paused");
importJobQueue.setStatus("paused"); importJobQueue.setStatus("paused");
}} }}
> >
<span className="text-md">Pause</span> <i className="fa-solid fa-pause mr-2"></i> Pause
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
case "paused": case "paused":
return ( return (
<div> <div className="control">
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500" className="button is-success is-light"
onClick={() => { onClick={() => {
toggleQueue("resume", "running"); toggleQueue("resume", "running");
importJobQueue.setStatus("running"); importJobQueue.setStatus("running");
}} }}
> >
<span className="text-md">Resume</span> <i className="fa-solid fa-play mr-2"></i> Resume
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
</span>
</button> </button>
</div> </div>
); );
@@ -255,224 +128,146 @@ export const Import = (props: ImportProps): ReactElement => {
return null; return null;
} }
}; };
return ( return (
<div> <div className="container">
<section> <section className="section is-small">
<header className="bg-slate-200 dark:bg-slate-500"> <h1 className="title">Import Comics</h1>
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <article className="message is-dark">
<div className="sm:flex sm:items-center sm:justify-between"> <div className="message-body">
<div className="text-center sm:text-left"> <p className="mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl"> <span className="tag is-medium is-info is-light">
Import Import Comics
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Import comics into the ThreeTwo library.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p>
Importing will add comics identified from the mapped folder into
ThreeTwo's database.
</p>
<p>
Metadata from ComicInfo.xml, if present, will also be extracted.
</p>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div>
</article>
{/* 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>
)}
{/* Active Session Warning */}
{hasActiveSession && !hasNewFiles && (
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div className="flex items-start gap-3">
<span className="w-6 h-6 text-yellow-600 dark:text-yellow-400 mt-0.5">
<i className="h-6 w-6 icon-[solar--info-circle-bold]"></i>
</span>
<div className="flex-1">
<p className="font-semibold text-yellow-800 dark:text-yellow-300">
Import In Progress
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
An import session is currently active. New imports cannot be started until it completes.
</p>
</div>
</div>
</div>
)}
{/* Import Action Buttons */}
<div className="my-6 max-w-screen-lg flex flex-col sm:flex-row gap-3">
{/* Start Smart Import Button - shown when there are new files, no active session, and no import is running */}
{hasNewFiles &&
!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleStartSmartImport}
disabled={isStartingImport || hasActiveSession}
>
<span className="text-md font-medium">
{isStartingImport
? "Starting Import..."
: importStats?.getImportStatistics?.stats?.alreadyImported === 0
? `Start Import (${importStats?.getImportStatistics?.stats?.newFiles} files)`
: `Start Incremental Import (${importStats?.getImportStatistics?.stats?.newFiles} new files)`
}
</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
</button>
)}
{/* Force Re-Import Button - always shown when no import is running */}
{!hasActiveSession &&
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-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> </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={
importJobQueue.status === "drained" ||
importJobQueue.status === undefined
? "button is-medium"
: "button is-loading is-medium"
}
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Start Import</span>
</button>
</p>
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200"> {importJobQueue.status !== "drained" &&
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md"> !isUndefined(importJobQueue.status) && (
<thead className="ltr:text-left rtl:text-right"> <>
<tr> <table className="table">
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <thead>
# <tr>
</th> <th>Completed Jobs</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> <th>Failed Jobs</th>
Time Started <th>Queue Controls</th>
</th> <th>Queue Status</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200"> </tr>
Session Id </thead>
</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"> <tbody>
{data?.getJobResultStatistics.map((jobResult: any, index: number) => { <tr>
return ( <th>
<tr key={index}> {importJobQueue.successfulJobCount > 0 && (
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium"> <div className="box has-background-success-light has-text-centered">
{index + 1} <span className="is-size-2 has-text-weight-bold">
</td> {importJobQueue.successfulJobCount}
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> </span>
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp)) </div>
? format( )}
new Date(parseInt(jobResult.earliestTimestamp)), </th>
"EEEE, hh:mma, do LLLL y", <td>
) {importJobQueue.failedJobCount > 0 && (
: "N/A"} <div className="box has-background-danger has-text-centered">
</td> <span className="is-size-2 has-text-weight-bold">
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300"> {importJobQueue.failedJobCount}
<span className="tag is-warning"> </span>
{jobResult.sessionId} </div>
</span> )}
</td> </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"> <td>{renderQueueControls(importJobQueue.status)}</td>
{jobResult.failedJobs} <td>
</p> {importJobQueue.status !== undefined ? (
</span> <span className="tag is-warning">
</td> {importJobQueue.status}
</tr> </span>
); ) : null}
})} </td>
</tbody> </tr>
</table> </tbody>
</div> </table>
</div> Imported{" "}
<span className="has-text-weight-bold">
{importJobQueue.mostRecentImport}
</span>
</>
)} )}
</div>
{/* Past imports */}
{!isLoading && !isEmpty(data?.data) && (
<>
<h3 className="subtitle is-4 mt-5">Past Imports</h3>
<table className="table is-striped">
<thead>
<tr>
<th>Time Started</th>
<th>Session Id</th>
<th>Imported</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{data?.data.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> </section>
</div> </div>
); );

View File

@@ -1,231 +0,0 @@
import React, { ReactElement, useEffect, useState } from "react";
import {
useGetImportStatisticsQuery,
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
* Updates in real-time via the useImportSessionStatus hook
*/
export const RealTimeImportStats = (): ReactElement => {
const [importError, setImportError] = useState<string | null>(null);
const { getSocket, disconnectSocket, importJobQueue } = useStore(
useShallow((state) => ({
getSocket: state.getSocket,
disconnectSocket: state.disconnectSocket,
importJobQueue: state.importJobQueue,
}))
);
// Get filesystem statistics (new files vs already imported)
const { data: importStats, isLoading, refetch: refetchStats } = useGetImportStatisticsQuery(
{},
{ refetchOnWindowFocus: false, refetchInterval: false }
);
// Get definitive import session status (handles Socket.IO events internally)
const importSession = useImportSessionStatus();
const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({
onSuccess: (data) => {
if (data.startIncrementalImport.success) {
importJobQueue.setStatus("running");
setImportError(null);
}
},
onError: (error: any) => {
console.error("Failed to start import:", error);
setImportError(error?.message || "Failed to start import. Please try again.");
},
});
const stats = importStats?.getImportStatistics?.stats;
const hasNewFiles = stats && stats.newFiles > 0;
// Refetch filesystem stats when import completes
useEffect(() => {
if (importSession.isComplete && importSession.status === "completed") {
console.log("[RealTimeImportStats] Import completed, refetching filesystem stats");
refetchStats();
importJobQueue.setStatus("drained");
}
}, [importSession.isComplete, importSession.status, refetchStats, importJobQueue]);
// Listen to filesystem change events to refetch stats
useEffect(() => {
const socket = getSocket("/");
const handleFilesystemChange = () => {
refetchStats();
};
// File system changes that affect import statistics
socket.on("LS_FILE_ADDED", handleFilesystemChange);
socket.on("LS_FILE_REMOVED", handleFilesystemChange);
socket.on("LS_FILE_CHANGED", handleFilesystemChange);
socket.on("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.on("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.on("LS_LIBRARY_STATISTICS", handleFilesystemChange);
return () => {
socket.off("LS_FILE_ADDED", handleFilesystemChange);
socket.off("LS_FILE_REMOVED", handleFilesystemChange);
socket.off("LS_FILE_CHANGED", handleFilesystemChange);
socket.off("LS_DIRECTORY_ADDED", handleFilesystemChange);
socket.off("LS_DIRECTORY_REMOVED", handleFilesystemChange);
socket.off("LS_LIBRARY_STATISTICS", handleFilesystemChange);
};
}, [getSocket, refetchStats]);
const handleStartImport = async () => {
setImportError(null);
// Check if import is already active using definitive status
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>;
}
// Determine button text based on whether there are already imported files
const isFirstImport = stats.alreadyImported === 0;
const buttonText = isFirstImport
? `Start Import (${stats.newFiles} files)`
: `Start Incremental Import (${stats.newFiles} new files)`;
// Calculate display statistics
const displayStats = importSession.isActive && importSession.stats
? {
totalFiles: importSession.stats.filesQueued + stats.alreadyImported,
filesQueued: importSession.stats.filesQueued,
filesSucceeded: importSession.stats.filesSucceeded,
}
: {
totalFiles: stats.totalLocalFiles,
filesQueued: stats.newFiles,
filesSucceeded: stats.alreadyImported,
};
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"
>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
</span>
</button>
</div>
</div>
)}
{/* Import Button - only show when there are new files and no active import */}
{hasNewFiles && !importSession.isActive && (
<button
onClick={handleStartImport}
disabled={isStartingImport}
className="w-full flex items-center justify-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"
>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
</span>
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
</button>
)}
{/* Active Import Progress Bar */}
{importSession.isActive && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Importing {importSession.stats?.filesSucceeded || 0} / {importSession.stats?.filesQueued || 0}...
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{Math.round(importSession.progress)}%
</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: `${importSession.progress}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
</div>
</div>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Files Detected Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#6b7280' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.totalFiles}
</div>
<div className="text-sm text-gray-200 font-medium">
files detected
</div>
</div>
{/* To Import Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#60a5fa' }}>
<div className="text-4xl font-bold text-white mb-2">
{displayStats.filesQueued}
</div>
<div className="text-sm text-gray-100 font-medium">
to import
</div>
</div>
{/* Already Imported Card */}
<div className="rounded-lg p-6 text-center" style={{ backgroundColor: '#d8dab2' }}>
<div className="text-4xl font-bold text-gray-800 mb-2">
{displayStats.filesSucceeded}
</div>
<div className="text-sm text-gray-700 font-medium">
already imported
</div>
</div>
</div>
</div>
);
};
export default RealTimeImportStats;

View File

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

View File

@@ -72,11 +72,8 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
> >
<div className="content is-flex is-flex-direction-row"> <div className="content is-flex is-flex-direction-row">
{!isEmpty(sourcedMetadata.comicvine) && ( {!isEmpty(sourcedMetadata.comicvine) && (
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0"> <span className="icon cv-icon is-small">
<img <img src="/src/client/assets/img/cvlogo.svg" />
src="/src/client/assets/img/cvlogo.svg"
className="w-full h-full object-contain"
/>
</span> </span>
)} )}
{isNil(rawFileDetails) && ( {isNil(rawFileDetails) && (

View File

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

View File

@@ -99,7 +99,7 @@ export const PullList = (): ReactElement => {
[], [],
); );
return ( return (
<section className="container mx-auto px-4 sm:px-6 lg:px-8"> <section className="container">
<div className="section"> <div className="section">
<div className="header-area"> <div className="header-area">
<h1 className="title">Weekly Pull List</h1> <h1 className="title">Weekly Pull List</h1>

View File

@@ -1,470 +1,180 @@
import React, { ReactElement, useState } from "react"; import React, { useCallback, ReactElement } from "react";
import { isNil, isEmpty, isUndefined } from "lodash"; import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings"; import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; 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 { Form, Field } from "react-final-form";
import Card from "../shared/Carda"; import Card from "../shared/Carda";
import ellipsize from "ellipsize"; import ellipsize from "ellipsize";
import { convert } from "html-to-text"; 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 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 {} interface ISearchProps {}
export const Search = ({}: ISearchProps): ReactElement => { export const Search = ({}: ISearchProps): ReactElement => {
const queryClient = useQueryClient();
const formData = { const formData = {
search: "", search: "",
}; };
const [comicVineMetadata, setComicVineMetadata] = useState({}); const dispatch = useDispatch();
const [selectedResource, setSelectedResource] = useState("volume"); const getCVSearchResults = useCallback(
const { t } = useTranslation(); (searchQuery) => {
const handleResourceChange = (value) => { dispatch(
setSelectedResource(value); comicinfoAPICall({
}; callURIAction: "search",
callMethod: "GET",
const { callParams: {
mutate, api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
data: comicVineSearchResults, query: searchQuery.search,
isPending, format: "json",
isSuccess, limit: "10",
} = useMutation({ offset: "0",
mutationFn: async (data: { search: string; resource: string }) => { field_list:
const { search, resource } = data; "id,name,deck,api_detail_url,image,description,volume,cover_date",
return await axios({ resources: "issue",
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: () => { [dispatch],
// Invalidate and refetch wanted comics queries );
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
const addToLibrary = (sourceName: string, comicData) => const addToLibrary = useCallback(
setComicVineMetadata({ sourceName, comicData }); (sourceName: string, comicData) =>
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => { const createDescriptionMarkup = (html) => {
return { __html: html }; return { __html: html };
}; };
const onSubmit = async (values) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
return ( return (
<div> <>
<section> <section className="container">
<header className="bg-slate-200 dark:bg-slate-500"> <div className="section search">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4"> <h1 className="title">Search</h1>
<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 <Form
onSubmit={onSubmit} onSubmit={getCVSearchResults}
initialValues={{ initialValues={{
...formData, ...formData,
}} }}
render={({ handleSubmit, form, submitting, pristine, values }) => ( render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className="form columns search">
<div className="flex flex-row w-full"> <div className="column is-three-quarters search">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full"> <Field name="search">
<div className="w-10 text-gray-400"> {({ input, meta }) => {
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" /> return (
</div> <input
{...input}
<Field name="search"> className="input main-search-bar is-large"
{({ input, meta }) => { placeholder="Type an issue/volume name"
return ( />
<input );
{...input} }}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full" </Field>
placeholder="Type an issue/volume name" </div>
/> <div className="column">
); <button type="submit" className="button is-medium">
}}
</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 Search
</button> </button>
</div> </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> </form>
)} )}
/> />
</div> {!isNil(comicVineSearchResults.results) &&
{isPending && ( !isEmpty(comicVineSearchResults.results) ? (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="columns is-multiline">
Loading results... {comicVineSearchResults.results.map((result) => {
</div> return (
)}
{!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 <div
key={result.id} key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg" className="comicvine-metadata column is-10 mb-3"
> >
<div className="flex flex-row"> <div className="columns">
<div className="mr-5 min-w-[80px] max-w-[13%]"> <div className="column is-one-quarter">
<Card <Card
key={result.id} key={result.id}
orientation={"cover-only"} orientation={"vertical"}
imageUrl={result.image.small_url} imageUrl={result.image.small_url}
title={result.name}
hasDetails={false} hasDetails={false}
/> ></Card>
</div> </div>
<div className="w-3/4"> <div className="column">
<div className="text-xl"> <div className="is-size-3">
{!isEmpty(result.name) ? ( {!isEmpty(result.name) ? (
result.name result.name
) : ( ) : (
<span className="text-xl">No Name</span> <span className="is-size-3">No Name</span>
)} )}
{result.start_year && <> ({result.start_year})</>}
</div> </div>
<div className="field is-grouped mt-1">
<div className="flex flex-row gap-2"> <div className="control">
{/* issue count */} <div className="tags has-addons">
{result.count_of_issues && ( <span className="tag is-light">Cover date</span>
<div className="my-2"> <span className="tag is-info is-light">
<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"> {dayjs(result.cover_date).format("MMM D, YYYY")}
<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> </span>
</div> </div>
)} </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"> <div className="control">
{ <div className="tags has-addons">
detectIssueTypes(result.description) <span className="tag is-warning">
.displayName {result.id}
} </span>
</span> </div>
</span> </div>
</div>
)}
</>
)}
</div> </div>
<span className="tag is-warning">{result.id}</span> <a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p> <p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{ellipsize( {ellipsize(
convert(result.description, { convert(result.description, {
baseElements: { baseElements: {
selectors: ["p", "div"], selectors: ["p"],
}, },
}), }),
320, 320,
)} )}
</p> </p>
<div className="mt-2"> <button
<PopoverButton className="button is-success is-light is-outlined mt-2"
content={`Adding this volume will add ${t( onClick={() => addToLibrary("comicvine", result)}
"issueWithCount", >
{ <i className="fa-solid fa-plus mr-2"></i> Want
count: result.count_of_issues, </button>
},
)} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: true,
resourceType: "volume",
})
}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
) );
); })}
})} </div>
</div> ) : (
) : ( <article className="message is-dark is-half">
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8"> <div className="message-body">
<article <p className="mb-2">
role="alert" <span className="tag is-medium is-info is-light">
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" Search the ComicVine database
> </span>
<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 Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or library. Then, download them using the configured AirDC++ or
torrent clients. torrent clients.
</p> </p>
</div> </div>
</article> </article>
</div> )}
)} </div>
</section> </section>
</div> </>
); );
}; };

View File

@@ -1,20 +1,27 @@
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form"; import { Form, Field } from "react-final-form";
import { isEmpty, isNil, isUndefined } from "lodash"; import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select"; import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../../store";
import axios from "axios"; import axios from "axios";
import { produce } from "immer";
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
export const AirDCPPHubsForm = (): ReactElement => { export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
} = useStore((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
}));
const { const {
data: settings, data: settings,
isLoading, isLoading,
isError, isError,
refetch,
} = useQuery({ } = useQuery({
queryKey: ["settings"], queryKey: ["settings"],
queryFn: async () => queryFn: async () =>
@@ -22,31 +29,23 @@ export const AirDCPPHubsForm = (): ReactElement => {
url: "http://localhost:3000/api/settings/getAllSettings", url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET", method: "GET",
}), }),
staleTime: Infinity,
}); });
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({ const { data: hubs } = useQuery({
queryKey: ["hubs"], queryKey: ["hubs"],
queryFn: async () => queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
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 = {};
let hubList: any[] = [];
if (!isNil(hubs)) { if (!isNil(hubs)) {
hubList = hubs?.data.map(({ hub_url, identity }) => ({ hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url, value: hub_url,
label: identity.name, label: identity.name,
})); }));
} }
const { mutate } = useMutation({
const mutation = useMutation({
mutationFn: async (values) => mutationFn: async (values) =>
await axios({ await axios({
url: `http://localhost:3000/api/settings/saveSettings`, url: `http://localhost:3000/api/settings/saveSettings`,
@@ -57,112 +56,79 @@ export const AirDCPPHubsForm = (): ReactElement => {
settingsKey: "directConnect", settingsKey: "directConnect",
}, },
}), }),
onSuccess: (data) => { onSuccess: () => {
queryClient.setQueryData(["settings"], (oldData: any) => queryClient.invalidateQueries({ queryKey: ["settings"] });
produce(oldData, (draft: any) => {
draft.data.directConnect.client = {
...draft.data.directConnect.client,
...data.data.directConnect.client,
};
}),
);
}, },
}); });
const validate = async () => {};
const validate = async (values) => {
const errors = {};
// Add any validation logic here if needed
return errors;
};
const SelectAdapter = ({ input, ...rest }) => { const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />; return <Select {...input} {...rest} isClearable isMulti />;
}; };
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error loading settings.</div>;
}
return ( return (
<> <>
{!isEmpty(hubList) && !isUndefined(hubs) ? ( {!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form <Form
onSubmit={(values) => { onSubmit={mutate}
mutation.mutate(values);
}}
validate={validate} validate={validate}
render={({ handleSubmit }) => ( render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} className="mt-10"> <form onSubmit={handleSubmit}>
<h2 className="text-xl">Configure DC++ Hubs</h2> <div>
<article <h3 className="title">Hubs</h3>
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"> <h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against. Your Select the hubs you want to perform searches against.
selection in the dropdown <strong>will replace</strong> the
existing selection.
</h6> </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> </div>
<button <div className="field">
type="submit" <label className="label">AirDC++ Host</label>
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" <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 Submit
</button> </button>
</form> </form>
)} )}
/> />
) : ( ) : (
<article <>
role="alert" <article className="message">
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 />
<div className="message-body"> Configure to a hub in AirDC++ and then select a default hub here.
No configured hubs detected in AirDC++. <br /> </div>
Configure to a hub in AirDC++ and then select a default hub here. </article>
</div> </>
</article>
)} )}
{!isEmpty(settings?.data.directConnect?.client.hubs) ? ( {!isEmpty(settings?.data.directConnect?.client.hubs) ? (
<> <>
<div className="mt-4"> <div className="mt-4">
<article className="message is-warning"> <article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary"></div> <div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article> </article>
</div> </div>
<div> <div className="box mt-3">
<span className="flex items-center mt-10 mb-4"> <h6>Default Hub For Searches:</h6>
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5"> {settings?.data.directConnect?.client.hubs.map(
Default Hub for Searches ({ value, label }) => (
</span> <div key={value}>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span> <div>{label}</div>
</span> <span className="is-size-7">{value}</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"> </div>
{settings?.data.directConnect?.client.hubs.map( ),
({ value, label }) => ( )}
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</div>
</div> </div>
</> </>
) : null} ) : null}

View File

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

View File

@@ -1,64 +1,77 @@
import React, { useState, useEffect } from "react"; import React, { ReactElement, useCallback } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation"; import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { isUndefined, isEmpty } from "lodash";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm"; import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { initializeAirDCPPSocket, useStore } from "../../../store/index";
import { useShallow } from "zustand/react/shallow";
import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import {
AIRDCPP_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
} from "../../../constants/endpoints";
export const AirDCPPSettingsForm = () => { export const AirDCPPSettingsForm = (): ReactElement => {
const [airDCPPSessionInformation, setAirDCPPSessionInformation] = // cherry-picking selectors for:
useState(null); // 1. initial values for the form
// Fetching all settings // 2. If initial values are present, get the socket information to display
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({ const { setState } = useStore;
queryKey: ["airDCPPSettings"], const {
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`), airDCPPSocketConnected,
}); airDCPPDisconnectionInfo,
airDCPPSessionInformation,
// Fetch session information airDCPPClientConfiguration,
const fetchSessionInfo = (host) => { airDCPPSocketInstance,
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host }); setAirDCPPSocketInstance,
}; } = useStore(
useShallow((state) => ({
// Use effect to trigger side effects on settings fetch success airDCPPSocketConnected: state.airDCPPSocketConnected,
useEffect(() => { airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) { airDCPPClientConfiguration: state.airDCPPClientConfiguration,
const host = settingsData.data.directConnect.client.host; airDCPPSessionInformation: state.airDCPPSessionInformation,
fetchSessionInfo(host).then((response) => { airDCPPSocketInstance: state.airDCPPSocketInstance,
setAirDCPPSessionInformation(response.data); setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
}); })),
}
}, [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 ?? {}; /**
* Mutation to update settings and subsequently initialize
* AirDC++ socket with those settings
*/
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "directConnect" },
}),
onSuccess: async (values) => {
const {
data: {
directConnect: {
client: { host },
},
},
} = values;
const dcppSocketInstance = await initializeAirDCPPSocket(host);
setState({
airDCPPClientConfiguration: host,
airDCPPSocketInstance: dcppSocketInstance,
});
},
});
const deleteSettingsMutation = useMutation(
async () =>
await axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
// const removeSettings = useCallback(async () => {
// // airDCPPSettings.setSettings({});
// }, []);
//
const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration
: {};
console.log(airDCPPClientConfiguration);
return ( return (
<> <>
<ConnectionForm <ConnectionForm
@@ -67,12 +80,13 @@ export const AirDCPPSettingsForm = () => {
formHeading={"Configure AirDC++"} formHeading={"Configure AirDC++"}
/> />
{airDCPPSessionInformation && ( {!isEmpty(airDCPPSessionInformation) ? (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} /> <AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
)} ) : null}
{settingsData?.data && ( {!isEmpty(airDCPPClientConfiguration) ? (
<p className="control mt-4"> <p className="control mt-4">
as
<button <button
className="button is-danger" className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()} onClick={() => deleteSettingsMutation.mutate()}
@@ -80,7 +94,7 @@ export const AirDCPPSettingsForm = () => {
Delete Delete
</button> </button>
</p> </p>
)} ) : null}
</> </>
); );
}; };

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

View File

@@ -3,8 +3,6 @@ import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm"; import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm"; import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm"; import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
import DockerVars from "./DockerVars/DockerVars";
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses"; import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
import settingsObject from "../../constants/settings/settingsMenu.json"; import settingsObject from "../../constants/settings/settingsMenu.json";
import { isUndefined, map } from "lodash"; import { isUndefined, map } from "lodash";
@@ -13,133 +11,110 @@ interface ISettingsProps {}
export const Settings = (props: ISettingsProps): ReactElement => { export const Settings = (props: ISettingsProps): ReactElement => {
const [active, setActive] = useState("gen-db"); 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 = [ const settingsContent = [
{ id: "adc-hubs", content: <AirDCPPHubsForm /> }, {
{ id: "adc-connection", content: <AirDCPPSettingsForm /> }, id: "adc-hubs",
{id: "gen-docker-vars", content: <DockerVars />}, content: (
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> }, <div key="adc-hubs">
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> }, <AirDCPPHubsForm />
{ 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> </div>
</section> ),
</div> },
{
id: "adc-connection",
content: (
<div key="adc-connection">
<AirDCPPSettingsForm />
</div>
),
},
{
id: "qbt-connection",
content: (
<div key="qbt-connection">
<QbittorrentConnectionForm />
</div>
),
},
{
id: "core-service",
content: <>a</>,
},
{
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>
); );
}; };

View File

@@ -48,11 +48,13 @@ export const SystemSettingsForm = (): ReactElement => {
</article> </article>
<button <button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500" className={
isLoading ? "button is-danger is-loading" : "button is-danger"
}
onClick={() => flushDb()} onClick={() => flushDb()}
> >
<span className="pt-1 px-1"> <span className="icon">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i> <i className="fas fa-eraser"></i>
</span> </span>
<span>Flush DB & Temporary Folders</span> <span>Flush DB & Temporary Folders</span>
</button> </button>

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -4,35 +4,17 @@ import { isEmpty, isNil } from "lodash";
interface ICardProps { interface ICardProps {
orientation: string; orientation: string;
imageUrl?: string; imageUrl: string;
hasDetails?: boolean; hasDetails: boolean;
title?: PropTypes.ReactElementLike | null; title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike; children?: PropTypes.ReactNodeLike;
borderColorClass?: string; borderColorClass?: string;
backgroundColor?: string; backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
cardContainerStyle?: React.CSSProperties; cardContainerStyle?: PropTypes.object;
imageStyle?: React.CSSProperties; imageStyle?: PropTypes.object;
} }
const getCardStateClass = (cardState?: string): string => {
switch (cardState) {
case "wanted":
return "bg-card-wanted";
case "delete":
return "bg-card-delete";
case "scraped":
return "bg-card-scraped";
case "uncompressed":
return "bg-card-uncompressed";
case "imported":
return "bg-card-imported";
default:
return "";
}
};
const renderCard = (props: ICardProps): ReactElement => { const renderCard = (props: ICardProps): ReactElement => {
switch (props.orientation) { switch (props.orientation) {
case "horizontal": case "horizontal":
@@ -98,87 +80,6 @@ const renderCard = (props: ICardProps): ReactElement => {
</div> </div>
</div> </div>
); );
case "vertical-2":
return (
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);
case "horizontal-small":
return (
<>
<div className={`flex flex-row justify-start align-top gap-3 h-fit rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} className="object-cover h-20 w-20" />
</div>
{/* details */}
<div className="w-fit h-fit pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
</div>
</div>
</>
);
case "horizontal-medium":
return (
<>
<div className={`flex flex-row items-center align-top gap-3 h-fit p-2 rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
{/* thumbnail */}
<div className="rounded-md overflow-hidden">
<img src={props.imageUrl} />
</div>
{/* details */}
<div className="pl-1 pr-2 py-1">
<p className="text-sm">{props.title}</p>
{props.hasDetails && <>{props.children}</>}
</div>
</div>
</>
);
case "cover-only":
return (
<>
{/* thumbnail */}
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
</>
);
case "card-with-info-panel":
return (
<>
<div className="flex flex-row">
{/* thumbnail */}
<div className="rounded-md overflow-hidden w-fit h-fit">
<img src={props.imageUrl} />
</div>
{/* myata-dyata */}
</div>
</>
);
default: default:
return <></>; return <></>;
} }

View File

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

View File

@@ -1,98 +0,0 @@
import React, { useRef, useState } from "react";
import { format } from "date-fns";
import FocusTrap from "focus-trap-react";
import { ClassNames, DayPicker } from "react-day-picker";
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
import styles from "react-day-picker/dist/style.module.css";
export const DatePickerDialog = (props) => {
const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const classNames: ClassNames = {
...styles,
head: "custom-head",
};
const buttonRef = useRef<HTMLButtonElement>(null);
const { x, y, reference, floating, strategy, refs, update } = useFloating({
placement: "bottom-end",
middleware: [offset(10), flip()],
strategy: "absolute",
});
const closePopper = () => {
setIsPopperOpen(false);
buttonRef.current?.focus();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
if (refs.reference.current && refs.floating.current) {
autoUpdate(refs.reference.current, refs.floating.current, update);
}
};
const handleDaySelect = (date) => {
setSelected(date);
if (date) {
setter(format(date, "yyyy/MM/dd"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={reference}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
onClick={handleButtonClick}
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
ref={floating}
style={{
position: strategy,
zIndex: "999",
borderRadius: "10px",
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
}}
className="bg-slate-400 dark:bg-slate-500"
role="dialog"
aria-label="DayPicker calendar"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
classNames={classNames}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

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

View File

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

View File

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

View File

@@ -31,62 +31,61 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "rawFileDetails", name: "rawFileDetails",
content: () => ( content: () => (
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg"> <dl>
<dt> <dt>
<p className="text-sm sm:text-lg">{issueName}</p> <h6
className="name has-text-weight-medium mb-1"
style={props.titleStyle}
>
{issueName}
</h6>
</dt> </dt>
<dd className="text-xs sm:text-sm"> <dd className="is-size-7">
is a part of{" "} Is a part of{" "}
<span className="underline"> <span className="has-text-weight-semibold">
{inferredMetadata.issue.name} {inferredMetadata.issue.name}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span> </span>
</dd> </dd>
{/* Issue number */} <dd className="is-size-7 mt-2">
{inferredMetadata.issue.number && ( <div className="field is-grouped is-grouped-multiline">
<dd className="my-2"> <div className="control">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="tags">
<span className="pr-1 pt-1"> <span
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i> className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.extension}
</span>
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{rawFileDetails.mimeType}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span> </span>
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900"> </div>
{inferredMetadata.issue.number} <div className="control">
</span> {inferredMetadata.issue.number && (
</span> <div className="tags has-addons">
</dd> <span className="tag is-light" style={props.tagsStyle}>
)} Issue #
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max"> </span>
{/* File extension */} <span className="tag is-warning" style={props.tagsStyle}>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> {inferredMetadata.issue.number}
<span className="pr-1 pt-1"> </span>
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i> </div>
</span> )}
</div>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900"> </div>
{rawFileDetails.mimeType}
</span>
</span>
{/* size */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm: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-4 h-4 sm:w-5 sm:h-5"></i>
</span>
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span>
)}
</dd> </dd>
</dl> </dl>
), ),
@@ -114,6 +113,7 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span> </span>
</span> </span>
</dd> </dd>
<dd className="is-size-7"> <dd className="is-size-7">
<span> <span>
{ellipsize( {ellipsize(
@@ -126,13 +126,42 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)} )}
</span> </span>
</dd> </dd>
<dd className="is-size-7 mt-2"> <dd className="is-size-7 mt-2">
<span className="my-3 mx-2"> <div className="field is-grouped is-grouped-multiline">
{comicvine.volumeInformation.start_year} <div className="control">
</span> <span className="tags">
{comicvine.volumeInformation.count_of_issues} <span
ComicVine ID className="tag is-success is-light has-text-weight-semibold"
{comicvine.id} style={props.tagsStyle}
>
{comicvine.volumeInformation.start_year}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{comicvine.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
</dd> </dd>
</dl> </dl>
), ),
@@ -179,16 +208,25 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
}); });
return ( return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3"> <div className="column" style={props.containerStyle}>
<div className="w-32 sm:w-56 lg:w-52 shrink-0"> <div className="comic-detail issue-metadata">
<Card <dl>
imageUrl={url} <dd>
orientation={"cover-only"} <div className="columns mt-2">
hasDetails={false} <div className="column is-3">
imageStyle={props.imageStyle} <Card
/> imageUrl={url}
orientation={"vertical"}
hasDetails={false}
imageStyle={props.imageStyle}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">{metadataPanel.content()}</div>
</div>
</dd>
</dl>
</div> </div>
<div className="flex-1">{metadataPanel.content()}</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,312 @@
import React from "react";
import { SearchBar } from "../GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns";
import { useStore } from "../../store/index";
import { useShallow } from "zustand/react/shallow";
const Navbar: React.FunctionComponent = (props) => {
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPDownloadTick,
importJobQueue,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
importJobQueue: state.importJobQueue,
})),
);
// const downloadProgressTick = useSelector(
// (state: RootState) => state.airdcpp.downloadProgressData,
// );
//
// const airDCPPSocketConnectionStatus = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
// );
// const airDCPPSessionInfo = useSelector(
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
// );
// const socketDisconnectionReason = useSelector(
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
// );
return (
<nav className="navbar is-fixed-top">
<div className="navbar-brand">
<Link to="/dashboard" 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>
{!isEmpty(airDCPPDownloadTick) && (
<div className="pulsating-circle"></div>
)}
</a>
{!isEmpty(airDCPPDownloadTick) ? (
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<DownloadProgressTick data={airDCPPDownloadTick} />
</a>
</div>
) : null}
</div>
{!isUndefined(importJobQueue.status) &&
location.hash !== "#/import" ? (
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
</a>
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<ul>
{importJobQueue.successfulJobCount > 0 ? (
<li className="mb-2">
<span className="tag is-success mr-2">
{importJobQueue.successfulJobCount}
</span>
imported.
</li>
) : null}
{importJobQueue.failedJobCount > 0 ? (
<li>
<span className="tag is-danger mr-2">
{importJobQueue.failedJobCount}
</span>
failed to import.
</li>
) : null}
</ul>
</a>
</div>
</div>
) : null}
{/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{!isUndefined(airDCPPSessionInformation.user) ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
{/* AirDC++ Session Information */}
<p>
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(
airDCPPSessionInformation?.user.last_login,
),
"dd MMMM, yyyy",
)}
</span>
</p>
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInformation.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInformation.session_id}
</span>
</p>
</div>
</>
) : (
<>
<a className="navbar-link is-arrowless has-text-danger">
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
</div>
</>
)}
</div>
<div className="navbar-item has-dropdown is-hoverable is-mega">
<div className="navbar-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,130 +0,0 @@
import React, { ReactElement, useState } from "react";
import { Link } from "react-router-dom";
import { useDarkMode } from "../../hooks/useDarkMode";
export const Navbar2 = (): ReactElement => {
const [theme, setTheme] = useDarkMode();
const darkMode = theme === "dark";
const toggleDarkMode = () => {
setTheme(darkMode ? "light" : "dark");
};
return (
<header className="bg-white dark:bg-gray-900 border-b-2 border-gray-300 dark:border-slate-200">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-5">
<div className="flex items-center gap-8">
{/* Logo */}
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
{/* Main Navigation */}
<div className="flex flex-1 items-center justify-end md:justify-between">
<nav
aria-label="ThreeTwo Main Navigation"
className="hidden md:block"
>
<ul className="flex items-center gap-6 text-md">
<li>
<Link
to="/dashboard"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Dashboard
</Link>
</li>
<li>
<Link
to="/import"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Import
</Link>
</li>
<li>
<Link
to="/library"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Library
</Link>
</li>
<li>
<Link
to="/volumes"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
Volumes
</Link>
</li>
<li>
<a
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Downloads
</a>
</li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</ul>
</nav>
{/* Right-most Nav */}
<div className="flex items-center gap-4">
<ul className="flex items-center gap-6 text-md">
{/* Settings Icon and text */}
<li>
<Link
to="/settings"
className="flex items-center space-x-1 text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
>
<span className="w-5 h-5">
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
</span>
<span>Settings</span>
</Link>
</li>
<li>
{/* Light/Dark Mode toggle */}
<div className="flex items-center space-x-2">
<span className="text-gray-600 dark:text-white">Light</span>
<label
htmlFor="toggle"
className="relative inline-flex items-center"
>
<input
type="checkbox"
id="toggle"
className="sr-only"
checked={darkMode}
onChange={toggleDarkMode}
/>
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
<span
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
darkMode ? "translate-x-4" : ""
}`}
></span>
</label>
<span className="text-gray-600 dark:text-white">Dark</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</header>
);
};

View File

@@ -1,45 +0,0 @@
import React, { useState } from "react";
import { useFloating, offset, flip } from "@floating-ui/react-dom";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
const PopoverButton = ({ content, clickHandler }) => {
const [isVisible, setIsVisible] = useState(false);
// Use destructuring to obtain the reference and floating setters, among other values.
const { x, y, refs, strategy, floatingStyles } = useFloating({
placement: "right",
middleware: [offset(8), flip()],
strategy: "absolute",
});
const { t } = useTranslation();
return (
<div>
{/* Apply the reference setter directly to the ref prop */}
<button
ref={refs.setReference}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
aria-describedby="popover"
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={clickHandler}
>
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
Mark as Wanted
</button>
{isVisible && (
<div
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
style={floatingStyles}
className="text-sm bg-slate-400 p-2 rounded-md"
role="tooltip"
>
{content}
</div>
)}
</div>
);
};
export default PopoverButton;

View File

@@ -1,4 +1,7 @@
import React, { ReactElement, useMemo, useState } from "react"; import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -8,19 +11,7 @@ import {
PaginationState, PaginationState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
interface T2TableProps { export const T2Table = (tableOptions): ReactElement => {
sourceData?: unknown[];
totalPages?: number;
columns?: unknown[];
paginationHandlers?: {
nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown;
};
rowClickHandler?(...args: unknown[]): unknown;
children?: any;
}
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const { const {
sourceData, sourceData,
columns, columns,
@@ -79,50 +70,67 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
}); });
return ( return (
<div className="container max-w-fit"> <>
<div> <div className="columns table-controls">
<div className="flex flex-row gap-2 justify-between mt-6 mb-4"> {/* Search bar */}
{/* Search bar */} <div className="column is-half">
{tableOptions.children} <SearchBar />
</div>
{/* Pagination controls */} {/* pagination controls */}
<div className="text-sm text-gray-800 dark:text-slate-200"> <nav className="pagination columns">
<div className="mb-1"> <div className="mr-4 has-text-weight-semibold has-text-left">
<p className="is-size-5">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p> </p>
<div className="inline-flex flex-row mt-3"> <p>{totalPages} comics in all</p>
</div>
<div className="field has-addons">
<div className="control">
<button <button
className="button"
onClick={() => goToPreviousPage()} onClick={() => goToPreviousPage()}
disabled={pageIndex === 1} disabled={pageIndex === 1}
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
> >
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i> <i className="fas fa-chevron-left"></i>
</button> </button>
</div>
<div className="control">
<button <button
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1" className="button"
onClick={() => goToNextPage()} onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)} disabled={pageIndex > Math.floor(totalPages / pageSize)}
> >
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i> <i className="fas fa-chevron-right"></i>
</button> </button>
</div> </div>
</div>
</div>
</div>
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100"> <div className="field has-addons ml-5">
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900"> <p className="control">
{table.getHeaderGroups().map((headerGroup, groupIndex) => ( <button className="button">
<span className="icon is-small">
<i className="fa-solid fa-list"></i>
</span>
</button>
</p>
<p className="control">
<button className="button">
<Link to="/library-grid">
<span className="icon is-small">
<i className="fa-solid fa-image"></i>
</span>
</Link>
</button>
</p>
</div>
</div>
</nav>
</div>
<table className="table is-hoverable">
<thead>
{table.getHeaderGroups().map((headerGroup, idx) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header, index) => ( {headerGroup.headers.map((header, idx) => (
<th <th key={header.id} colSpan={header.colSpan}>
key={header.id}
colSpan={header.colSpan}
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400 border-b border-gray-300 dark:border-slate-700"
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
@@ -136,23 +144,31 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row, rowIndex) => ( {table.getRowModel().rows.map((row, idx) => {
<tr return (
key={row.id} <tr key={row.id} onClick={() => rowClickHandler(row)}>
onClick={() => rowClickHandler(row)} {row.getVisibleCells().map((cell) => (
className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer" <td key={cell.id}>
> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{row.getVisibleCells().map((cell) => ( </td>
<td key={cell.id} className="px-3 py-2 align-top"> ))}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </tr>
</td> );
))} })}
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </>
); );
}; };
T2Table.propTypes = {
sourceData: PropTypes.array,
totalPages: PropTypes.number,
columns: PropTypes.array,
paginationHandlers: PropTypes.shape({
nextPage: PropTypes.func,
previousPage: PropTypes.func,
}),
rowClickHandler: PropTypes.func,
};
export default T2Table; export default T2Table;

View File

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

View File

@@ -8,6 +8,7 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
options.apiPath options.apiPath
); );
}; };
console.log(import.meta);
export const CORS_PROXY_SERVER_URI = hostURIBuilder({ export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http", protocol: "http",
@@ -90,24 +91,3 @@ export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
port: "3060", port: "3060",
apiPath: `/api/qbittorrent`, apiPath: `/api/qbittorrent`,
}); });
export const PROWLARR_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/prowlarr`,
});
export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/torrentjobs`,
});
export const AIRDCPP_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/airdcpp`,
});

View File

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

View File

@@ -9,8 +9,8 @@
"displayName": "Dashboard" "displayName": "Dashboard"
}, },
{ {
"id": "gen-docker-vars", "id": "gen-gls",
"displayName": "Docker ENV vars" "displayName": "Global Search"
} }
] ]
}, },
@@ -57,7 +57,7 @@
"displayName": "Prowlarr", "displayName": "Prowlarr",
"children": [ "children": [
{ {
"id": "prwlr-connection", "id": "prowlarr-connection",
"displayName": "Connection" "displayName": "Connection"
}, },
{ {

View File

@@ -0,0 +1,11 @@
import React, { createContext } from "react";
export const SocketIOContext = createContext({});
export const SocketIOProvider = ({ children, socket }) => {
return (
<SocketIOContext.Provider value={socket}>
{children}
</SocketIOContext.Provider>
);
};

View File

@@ -1,76 +0,0 @@
import { GetComicByIdQuery } from '../generated';
/**
* Adapter to transform GraphQL Comic response to legacy REST API format
* This allows gradual migration while maintaining compatibility with existing components
*/
export function adaptGraphQLComicToLegacy(graphqlComic: GetComicByIdQuery['comic']) {
if (!graphqlComic) return null;
// Parse sourced metadata (GraphQL returns as strings)
const comicvine = graphqlComic.sourcedMetadata?.comicvine
? (typeof graphqlComic.sourcedMetadata.comicvine === 'string'
? JSON.parse(graphqlComic.sourcedMetadata.comicvine)
: graphqlComic.sourcedMetadata.comicvine)
: undefined;
const comicInfo = graphqlComic.sourcedMetadata?.comicInfo
? (typeof graphqlComic.sourcedMetadata.comicInfo === 'string'
? JSON.parse(graphqlComic.sourcedMetadata.comicInfo)
: graphqlComic.sourcedMetadata.comicInfo)
: undefined;
const locg = graphqlComic.sourcedMetadata?.locg || undefined;
// Use inferredMetadata from GraphQL response, or build from canonical metadata as fallback
const inferredMetadata = graphqlComic.inferredMetadata || {
issue: {
name: graphqlComic.canonicalMetadata?.title?.value ||
graphqlComic.canonicalMetadata?.series?.value ||
graphqlComic.rawFileDetails?.name || '',
number: graphqlComic.canonicalMetadata?.issueNumber?.value
? parseInt(graphqlComic.canonicalMetadata.issueNumber.value, 10)
: undefined,
year: graphqlComic.canonicalMetadata?.publicationDate?.value?.substring(0, 4) ||
graphqlComic.canonicalMetadata?.coverDate?.value?.substring(0, 4),
subtitle: graphqlComic.canonicalMetadata?.series?.value,
},
};
// Build acquisition data (if available from importStatus or other fields)
const acquisition = {
directconnect: {
downloads: [],
},
torrent: [],
};
// Transform rawFileDetails to match expected format
const rawFileDetails = graphqlComic.rawFileDetails ? {
name: graphqlComic.rawFileDetails.name || '',
filePath: graphqlComic.rawFileDetails.filePath,
fileSize: graphqlComic.rawFileDetails.fileSize,
extension: graphqlComic.rawFileDetails.extension,
mimeType: graphqlComic.rawFileDetails.mimeType,
containedIn: graphqlComic.rawFileDetails.containedIn,
pageCount: graphqlComic.rawFileDetails.pageCount,
archive: graphqlComic.rawFileDetails.archive,
cover: graphqlComic.rawFileDetails.cover,
} : undefined;
return {
_id: graphqlComic.id,
rawFileDetails,
inferredMetadata,
sourcedMetadata: {
comicvine,
locg,
comicInfo,
},
acquisition,
createdAt: graphqlComic.createdAt || new Date().toISOString(),
updatedAt: graphqlComic.updatedAt || new Date().toISOString(),
// Include the full GraphQL data for components that can use it
__graphql: graphqlComic,
} as any; // Use 'as any' to bypass strict type checking during migration
}

View File

@@ -1,43 +0,0 @@
import { LIBRARY_SERVICE_HOST } from '../constants/endpoints';
export function fetcher<TData, TVariables>(
query: string,
variables?: TVariables,
options?: RequestInit['headers']
) {
return async (): Promise<TData> => {
try {
const res = await fetch(`${LIBRARY_SERVICE_HOST}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options,
},
body: JSON.stringify({
query,
variables,
}),
});
// Check if the response is OK (status 200-299)
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const json = await res.json();
if (json.errors) {
const { message } = json.errors[0] || {};
throw new Error(message || 'Error fetching data');
}
return json.data;
} catch (error) {
// Handle network errors or other fetch failures
if (error instanceof Error) {
throw new Error(`Failed to fetch: ${error.message}`);
}
throw new Error('Failed to fetch data from server');
}
};
}

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