Compare commits
54 Commits
react-19-u
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf6415bb36 | ||
| e083c18c0e | |||
| 7818c6f290 | |||
| 46e683859e | |||
| a45eae2604 | |||
| a0d971e010 | |||
| aec989d021 | |||
| 2b4ee716e3 | |||
| ec52906eca | |||
| 07f5e6efe6 | |||
| 5d18bd1e43 | |||
| 74c0d6513c | |||
| 4b8d7b5905 | |||
| 92992449a9 | |||
| 59afeded6a | |||
| f9aac5e19f | |||
| a8ae4130a6 | |||
| 6f781af381 | |||
| c005d118ac | |||
| 4498830e29 | |||
| e113066094 | |||
| 0af9482be9 | |||
| 37a2d0c75b | |||
| b47b38cc8d | |||
|
|
5c99cfb28b | ||
|
|
880b6c44ff | ||
| b0c8c295c7 | |||
| d39e2cdca1 | |||
|
|
1a6154e0b4 | ||
|
|
d8e110e2e7 | ||
|
|
9189386593 | ||
| a10cb07d67 | |||
|
|
2a83855115 | ||
|
|
58b60a38b4 | ||
|
|
c339dc9df1 | ||
|
|
d8bcad88b7 | ||
|
|
ff1742998a | ||
|
|
de1fb349f6 | ||
|
|
698e2a89da | ||
|
|
0597180637 | ||
|
|
422b16abf8 | ||
| 80f0ced0b0 | |||
|
|
0def39cd73 | ||
|
|
1c60837ec9 | ||
|
|
d42a700cec | ||
|
|
40fd8ede5d | ||
|
|
24b09c9c5e | ||
| c176dab78b | |||
| c0b189c9e6 | |||
| f3333b5c2c | |||
| 2ce90d94c0 | |||
| 0e445ba3d4 | |||
| 9c5fb93d5b | |||
| 338a46224d |
31
Dockerfile
31
Dockerfile
@@ -1,23 +1,36 @@
|
||||
FROM node:18.15.0-alpine
|
||||
# Use Node.js 22 as the base image
|
||||
FROM node:22-alpine
|
||||
|
||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /threetwo
|
||||
|
||||
# Copy package.json and lock file first to leverage Docker cache
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
# Copy package.json and yarn.lock to leverage Docker cache
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install build dependencies necessary for native modules
|
||||
RUN apk --no-cache add g++ make libpng-dev git python3 autoconf automake libjpeg-turbo-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||
# Install build dependencies necessary for native modules (for node-sass)
|
||||
RUN apk --no-cache add \
|
||||
g++ \
|
||||
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
|
||||
|
||||
# Copy the rest of the application files into the container
|
||||
COPY . .
|
||||
|
||||
# Expose the application port (default for Vite)
|
||||
EXPOSE 5173
|
||||
|
||||
# Use yarn start if you want to stick with yarn, or change to npm start if you prefer npm
|
||||
ENTRYPOINT [ "yarn", "start" ]
|
||||
# Start the application with yarn
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
|
||||
1
__mocks__/fileMock.js
Normal file
1
__mocks__/fileMock.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
16
codegen.yml
Normal file
16
codegen.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
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
|
||||
28
jest.config.js
Normal file
28
jest.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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',
|
||||
],
|
||||
};
|
||||
25
jest.setup.js
Normal file
25
jest.setup.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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
13
nodemon.json
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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
Normal file
21441
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
225
package.json
225
package.json
@@ -2,131 +2,144 @@
|
||||
"name": "threetwo",
|
||||
"version": "0.1.0",
|
||||
"description": "ThreeTwo! A good comic book curator.",
|
||||
"main": "server/index.js",
|
||||
"typings": "server/index.js",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "rimraf dist && npm run build && vite",
|
||||
"start": "npm run build && vite",
|
||||
"dev": "rimraf dist && yarn build && vite",
|
||||
"start": "yarn build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"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",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@floating-ui/react": "^0.26.12",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.18",
|
||||
"@floating-ui/react-dom": "^2.1.7",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@tanstack/react-query": "^5.0.5",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||
"axios": "^1.7.4",
|
||||
"axios-cache-interceptor": "^1.0.1",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"airdcpp-apisocket": "^3.0.0-beta.14",
|
||||
"axios": "^1.13.5",
|
||||
"axios-cache-interceptor": "^1.11.4",
|
||||
"axios-rate-limit": "^1.6.2",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.5.1",
|
||||
"express": "^4.20.0",
|
||||
"filename-parser": "^1.0.2",
|
||||
"final-form": "^4.20.2",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"ellipsize": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"filename-parser": "^1.0.4",
|
||||
"final-form": "^5.0.0",
|
||||
"final-form-arrays": "^4.0.0",
|
||||
"focus-trap-react": "^12.0.0",
|
||||
"graphql": "^16.13.1",
|
||||
"history": "^5.3.0",
|
||||
"html-to-text": "^8.1.0",
|
||||
"i18next": "^23.11.1",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"i18next-http-backend": "^2.5.0",
|
||||
"immer": "^10.0.3",
|
||||
"jsdoc": "^3.6.10",
|
||||
"keen-slider": "^6.8.6",
|
||||
"lodash": "^4.17.21",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"immer": "^11.1.4",
|
||||
"jsdoc": "^4.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^18.3.1",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"react-final-form-arrays": "^3.1.4",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loader-spinner": "^4.0.0",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-router": "^6.9.0",
|
||||
"react-router-dom": "^6.9.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select-async-paginate": "^0.7.2",
|
||||
"react-sliding-pane": "^7.1.0",
|
||||
"react-textarea-autosize": "^8.3.4",
|
||||
"react-toastify": "^10.0.5",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^6.1.0",
|
||||
"qs": "^6.15.0",
|
||||
"react": "^19.2.4",
|
||||
"react-collapsible": "^2.10.0",
|
||||
"react-comic-viewer": "^0.5.1",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-final-form": "^7.0.0",
|
||||
"react-final-form-arrays": "^4.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-loader-spinner": "^8.0.2",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-select-async-paginate": "^0.7.11",
|
||||
"react-sliding-pane": "^7.3.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-toastify": "^11.0.5",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"vite": "^5.4.12",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"zustand": "^4.4.6"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"websocket": "^1.0.35",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/solar": "^1.1.8",
|
||||
"@iconify/tailwind": "^0.1.4",
|
||||
"@storybook/addon-essentials": "^7.4.1",
|
||||
"@storybook/addon-interactions": "^7.4.1",
|
||||
"@storybook/addon-links": "^7.4.1",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.4.1",
|
||||
"@storybook/react": "^7.4.1",
|
||||
"@storybook/react-vite": "^7.4.1",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.0.5",
|
||||
"@tanstack/react-query-devtools": "^5.1.0",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@types/ellipsize": "^0.1.1",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"body-parser": "^1.19.0",
|
||||
"@graphql-codegen/cli": "^6.1.2",
|
||||
"@graphql-codegen/typescript": "^5.0.8",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||
"@graphql-codegen/typescript-react-query": "^6.1.2",
|
||||
"@iconify-json/solar": "^1.2.5",
|
||||
"@iconify/json": "^2.2.443",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-interactions": "^8.6.17",
|
||||
"@storybook/addon-links": "^8.6.17",
|
||||
"@storybook/addon-onboarding": "^8.6.17",
|
||||
"@storybook/blocks": "^8.6.17",
|
||||
"@storybook/react": "^8.6.17",
|
||||
"@storybook/react-vite": "^8.6.17",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/ellipsize": "^0.1.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"docdash": "^2.0.2",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsdoc": "^46.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-storybook": "^0.6.13",
|
||||
"express": "^4.20.0",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-storybook": "^0.11.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"install": "^0.13.0",
|
||||
"jest": "^29.6.3",
|
||||
"nodemon": "^3.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-import": "^15.1.0",
|
||||
"prettier": "^2.2.1",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.77.0",
|
||||
"storybook": "^7.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"sass": "^1.97.3",
|
||||
"storybook": "^8.6.17",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "^5.9.3",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"jackspeak": "2.1.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,7 +55,6 @@ export const toggleAirDCPPSocketConnectionStatus =
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Can't set AirDC++ socket status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,10 +73,9 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Could not complete request.");
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
dispatch({
|
||||
type: CV_API_GENERIC_FAILURE,
|
||||
error,
|
||||
@@ -99,7 +98,6 @@ export const getIssuesForSeries =
|
||||
comicObjectID,
|
||||
},
|
||||
});
|
||||
console.log(issues);
|
||||
dispatch({
|
||||
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
issues: issues.data.results,
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
LS_SET_QUEUE_STATUS,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { success } from "react-notification-system-redux";
|
||||
|
||||
import { isNil } from "lodash";
|
||||
|
||||
@@ -151,7 +150,7 @@ export const getComicBooks = (options) => async (dispatch) => {
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.log("Unrecognized comic status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -219,12 +218,11 @@ export const fetchVolumeGroups = () => async (dispatch) => {
|
||||
data: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
export const fetchComicVineMatches =
|
||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||
console.log(issueSearchQuery);
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
@@ -273,7 +271,7 @@ export const fetchComicVineMatches =
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
|
||||
dispatch({
|
||||
|
||||
@@ -7,8 +7,6 @@ export const fetchMetronResource = async (options) => {
|
||||
`${METRON_SERVICE_URI}/fetchResource`,
|
||||
options,
|
||||
);
|
||||
console.log(metronResourceResults);
|
||||
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||
const results = metronResourceResults.data.results.map((result) => {
|
||||
return {
|
||||
label: result.name || result.__str__,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../assets/scss/App.scss";
|
||||
import "../assets/scss/App.css";
|
||||
import { useStore } from "../store";
|
||||
|
||||
export const App = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar2 />
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
@@ -10,6 +16,7 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
@@ -21,32 +28,65 @@ interface IAcquisitionPanelProps {
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
const socketRef = useRef<Socket>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
interface SearchData {
|
||||
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
||||
hub_urls: string[] | undefined | null;
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
// Add other properties as needed
|
||||
slots: any;
|
||||
type: any;
|
||||
users: any;
|
||||
name: string;
|
||||
dupe: Boolean;
|
||||
size: number;
|
||||
}
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
||||
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
// --- Handlers ---
|
||||
const handleResultAdded = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) =>
|
||||
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||
);
|
||||
};
|
||||
|
||||
const handleResultUpdated = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === result.id);
|
||||
if (idx === -1) return prev;
|
||||
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = result;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInitiated = (data: any) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
};
|
||||
|
||||
const handleSearchesSent = (data: any) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
};
|
||||
|
||||
// --- Subscribe once ---
|
||||
socket.on("searchResultAdded", handleResultAdded);
|
||||
socket.on("searchResultUpdated", handleResultUpdated);
|
||||
socket.on("searchInitiated", handleSearchInitiated);
|
||||
socket.on("searchesSent", handleSearchesSent);
|
||||
|
||||
return () => {
|
||||
socket.off("searchResultAdded", handleResultAdded);
|
||||
socket.off("searchResultUpdated", handleResultUpdated);
|
||||
socket.off("searchInitiated", handleSearchInitiated);
|
||||
socket.off("searchesSent", handleSearchesSent);
|
||||
// if you want to fully close the socket:
|
||||
// useStore.getState().disconnectSocket("/manual");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (searchQuery) => {
|
||||
// Use the already connected socket instance to emit events
|
||||
socketIOInstance.emit("initiateSearch", searchQuery);
|
||||
};
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
@@ -59,9 +99,7 @@ export const AcquisitionPanel = (
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () =>
|
||||
@@ -74,24 +112,8 @@ export const AcquisitionPanel = (
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
});
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<
|
||||
SearchResult[]
|
||||
>([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
||||
// Pre-populate the search input with the search string, so that
|
||||
// all the user has to do is hit "Search AirDC++" to perform a search
|
||||
useEffect(() => {
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
@@ -101,67 +123,22 @@ export const AcquisitionPanel = (
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}, []);
|
||||
}, [hubs, sanitizedIssueName]);
|
||||
|
||||
/**
|
||||
* Method to perform a search via an AirDC++ websocket
|
||||
* @param {SearchData} data - a SearchData query
|
||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||
*/
|
||||
const search = async (searchData: any) => {
|
||||
setAirDCPPSearchResults([]);
|
||||
socketIOInstance.emit("call", "socket.search", {
|
||||
socketRef.current?.emit("call", "socket.search", {
|
||||
query: searchData,
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
// hostname: `192.168.1.119:5600`,
|
||||
hostname: `127.0.0.1:5600`,
|
||||
username: `user`,
|
||||
password: `pass`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
socketIOInstance.on("searchResultAdded", ({ result }: any) => {
|
||||
setAirDCPPSearchResults((previousState) => {
|
||||
const exists = previousState.some((item) => result.id === item.id);
|
||||
if (!exists) {
|
||||
return [...previousState, result];
|
||||
}
|
||||
return previousState;
|
||||
});
|
||||
});
|
||||
|
||||
socketIOInstance.on("searchResultUpdated", ({ result }: any) => {
|
||||
// ...update properties of the existing result in the UI
|
||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||
(bundle) => bundle.id === result.id,
|
||||
);
|
||||
const updatedState = [...airDCPPSearchResults];
|
||||
if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) {
|
||||
updatedState[bundleToUpdateIndex] = result;
|
||||
}
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
});
|
||||
|
||||
socketIOInstance.on("searchInitiated", (data) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
});
|
||||
socketIOInstance.on("searchesSent", (data) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
});
|
||||
|
||||
/**
|
||||
* Method to download a bundle associated with a search result from AirDC++
|
||||
* @param {Number} searchInstanceId - description
|
||||
* @param {String} resultId - description
|
||||
* @param {String} comicObjectId - description
|
||||
* @param {String} name - description
|
||||
* @param {Number} size - description
|
||||
* @param {any} type - description
|
||||
* @param {any} config - description
|
||||
* @returns {void} - description
|
||||
*/
|
||||
const download = async (
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
@@ -171,7 +148,7 @@ export const AcquisitionPanel = (
|
||||
type: any,
|
||||
config: any,
|
||||
): Promise<void> => {
|
||||
socketIOInstance.emit(
|
||||
socketRef.current?.emit(
|
||||
"call",
|
||||
"socket.download",
|
||||
{
|
||||
@@ -183,9 +160,12 @@ export const AcquisitionPanel = (
|
||||
type,
|
||||
config,
|
||||
},
|
||||
(data: any) => console.log(data),
|
||||
(data: any) => {
|
||||
// Download initiated
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getDCPPSearchResults = async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
@@ -316,139 +296,114 @@ export const AcquisitionPanel = (
|
||||
{/* AirDC++ results */}
|
||||
<div className="">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
<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">
|
||||
<div className="overflow-x-auto max-w-full mt-6">
|
||||
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<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 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||
<tbody>
|
||||
{map(
|
||||
airDCPPSearchResults,
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(dupe)
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "w-fit text-sm"
|
||||
}
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
||||
<p className="mb-2">
|
||||
{type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}
|
||||
{ellipsize(name, 70)}
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-row gap-2">
|
||||
{!isNil(dupe) ? (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
Dupe
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Nicks */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--user-rounded-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{users.user.nicks}
|
||||
</span>
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(dupe)
|
||||
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
|
||||
: "border-b border-gray-200 dark:border-slate-700 text-sm"
|
||||
}
|
||||
>
|
||||
{/* NAME */}
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||
<p className="mb-2">
|
||||
{type.id === "directory" && (
|
||||
<i className="fas fa-folder mr-1"></i>
|
||||
)}
|
||||
{ellipsize(name, 45)}
|
||||
</p>
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-wrap gap-1">
|
||||
{!isNil(dupe) && (
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||
Dupe
|
||||
</span>
|
||||
{/* Flags */}
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
)}
|
||||
<span className="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>
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
|
||||
>
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||
{flag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{flag}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
{/* Extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{/* TYPE */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||
{type.str}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{type.str}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{/* Slots */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{/* SLOTS */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||
{slots.total} slots; {slots.free} free
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{slots.total} slots; {slots.free} free
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-xs">Download</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
{/* ACTIONS */}
|
||||
<td className="px-2 py-3">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
||||
onClick={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Download
|
||||
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,44 +3,55 @@ 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-4 rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<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>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<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="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<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="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Download Time
|
||||
<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="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
<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 className="divide-y divide-gray-200">
|
||||
{map(props.data, (bundle) => (
|
||||
<tr key={bundle.id} className="text-sm">
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||
<span className="text-xs">{ellipsize(bundle.target, 88)}</span>
|
||||
<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="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<td className="px-3 py-2 align-top">
|
||||
{prettyBytes(bundle.size)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{dayjs
|
||||
.unix(bundle.time_finished)
|
||||
.format("h:mm on ddd, D MMM, YYYY")}
|
||||
<td className="px-3 py-2 align-top">
|
||||
<DownloadProgressTick bundleId={bundle.id} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">{bundle.id}</span>
|
||||
<td 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>
|
||||
))}
|
||||
|
||||
@@ -1,40 +1,71 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
|
||||
import { RawFileDetails } from "./RawFileDetails";
|
||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||
|
||||
import TabControls from "./TabControls";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
import { Menu } from "./ActionMenu/Menu";
|
||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||
import AcquisitionPanel from "./AcquisitionPanel";
|
||||
import TorrentSearchPanel from "./TorrentSearchPanel";
|
||||
import DownloadsPanel from "./DownloadsPanel";
|
||||
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||
|
||||
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||
import { components } from "react-select";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
|
||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import Loader from "react-loader-spinner";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import Modal from "react-modal";
|
||||
import ComicViewer from "react-comic-viewer";
|
||||
|
||||
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import axios from "axios";
|
||||
import { styled } from "styled-components";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { refineQuery } from "filename-parser";
|
||||
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.
|
||||
*
|
||||
@@ -44,7 +75,6 @@ type ComicDetailProps = {};
|
||||
* <ComicDetail/>
|
||||
* )
|
||||
*/
|
||||
|
||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const {
|
||||
data: {
|
||||
@@ -57,132 +87,34 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
updatedAt,
|
||||
},
|
||||
userSettings,
|
||||
queryClient,
|
||||
comicObjectId: comicObjectIdProp,
|
||||
} = data;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [modalIsOpen, setIsOpen] = useState(false);
|
||||
const [comicVineMatches, setComicVineMatches] = useState([]);
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
||||
|
||||
// const dispatch = useDispatch();
|
||||
|
||||
const openModal = useCallback((filePath) => {
|
||||
// Modal handlers (currently unused but kept for future use)
|
||||
const openModal = useCallback((filePath: string) => {
|
||||
setIsOpen(true);
|
||||
// dispatch(
|
||||
// extractComicArchive(filePath, {
|
||||
// type: "full",
|
||||
// purpose: "reading",
|
||||
// imageResizeOptions: {
|
||||
// baseWidth: 1024,
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
}, []);
|
||||
|
||||
// overridden <SlidingPanel> with some styles
|
||||
const StyledSlidingPanel = styled(SlidingPane)`
|
||||
background: #ccc;
|
||||
`;
|
||||
const afterOpenModal = useCallback((things) => {
|
||||
// references are now sync'd and can be accessed.
|
||||
// subtitle.style.color = "#f00";
|
||||
console.log("kolaveri", things);
|
||||
const afterOpenModal = useCallback((things: any) => {
|
||||
// Modal opened callback
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
CVMatches: {
|
||||
content: (props) => (
|
||||
<>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
editComicBookMetadata: {
|
||||
content: () => <EditMetadataPanel data={rawFileDetails} />,
|
||||
},
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
const fetchComicVineMatches = async (
|
||||
searchPayload,
|
||||
issueSearchQuery,
|
||||
seriesSearchQuery,
|
||||
) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
// hack
|
||||
query: issueSearchQuery.inferredIssueDetails.name
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim(),
|
||||
limit: "100",
|
||||
page: 1,
|
||||
resources: "volume",
|
||||
scorerConfiguration: {
|
||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||
},
|
||||
rawFileDetails: searchPayload,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
// return sortBy(matches, (match) => -match.score);
|
||||
},
|
||||
});
|
||||
let matches: any = [];
|
||||
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
||||
matches = response.data.results;
|
||||
} else {
|
||||
matches = response.data.map((match) => match);
|
||||
}
|
||||
const scoredMatches = matches.sort((a, b) => b.score - a.score);
|
||||
setComicVineMatches(scoredMatches);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
|
||||
if (!isUndefined(rawFileDetails)) {
|
||||
issueSearchQuery = refineQuery(rawFileDetails.name);
|
||||
} else if (!isEmpty(comicvine)) {
|
||||
issueSearchQuery = refineQuery(comicvine.name);
|
||||
}
|
||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
setVisible(true);
|
||||
};
|
||||
@@ -192,47 +124,16 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// Actions menu options and handler
|
||||
const CVMatchLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Match on ComicVine</div>
|
||||
</span>
|
||||
);
|
||||
const editLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Edit Metadata</div>
|
||||
</span>
|
||||
);
|
||||
const deleteLabel = (
|
||||
<span className="inline-flex flex-row items-center gap-2">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Delete Comic</div>
|
||||
</span>
|
||||
);
|
||||
const Placeholder = (props) => {
|
||||
return <components.Placeholder {...props} />;
|
||||
};
|
||||
const actionOptions = [
|
||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||
{ value: "edit-metdata", label: editLabel },
|
||||
{ value: "delete-comic", label: deleteLabel },
|
||||
];
|
||||
|
||||
// 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) => {
|
||||
|
||||
const handleActionSelection = (action: ActionOption) => {
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
@@ -241,40 +142,14 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
openEditMetadataPanel();
|
||||
break;
|
||||
default:
|
||||
console.log("No valid action selected.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
const customStyles = {
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
}),
|
||||
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
|
||||
...base,
|
||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
paddingTop: "0.4rem",
|
||||
}),
|
||||
control: (base) => ({
|
||||
...base,
|
||||
backgroundColor: "rgb(156, 163, 175)",
|
||||
color: "black",
|
||||
border: "1px solid rgb(156, 163, 175)",
|
||||
}),
|
||||
};
|
||||
|
||||
// check for the availability of CV metadata
|
||||
// Check for metadata availability
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
||||
|
||||
// check for the availability of rawFileDetails
|
||||
const areRawFileDetailsAvailable =
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||
|
||||
@@ -284,110 +159,54 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
locg,
|
||||
});
|
||||
|
||||
// query for airdc++
|
||||
// Query for airdc++
|
||||
const airDCPPQuery = {
|
||||
issue: {
|
||||
name: issueName,
|
||||
},
|
||||
};
|
||||
|
||||
// Tab content and header details
|
||||
const tabGroup = [
|
||||
{
|
||||
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.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.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={_id}
|
||||
comicObject={data.data}
|
||||
userSettings={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={_id} issueName={issueName} />,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Downloads",
|
||||
icon: (
|
||||
<>
|
||||
{acquisition?.directconnect?.downloads?.length +
|
||||
acquisition?.torrent.length}
|
||||
</>
|
||||
),
|
||||
content:
|
||||
!isNil(data.data) && !isEmpty(data.data) ? (
|
||||
<DownloadsPanel key={5} />
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
];
|
||||
// filtered Tabs
|
||||
// Create tab configuration
|
||||
const tabGroup = createTabConfig({
|
||||
data: data.data,
|
||||
comicInfo,
|
||||
isComicBookMetadataAvailable,
|
||||
areRawFileDetailsAvailable,
|
||||
airDCPPQuery,
|
||||
comicObjectId: _id,
|
||||
userSettings,
|
||||
issueName,
|
||||
acquisition,
|
||||
});
|
||||
|
||||
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||
|
||||
// Determine which cover image to use:
|
||||
// 1. from the locally imported or
|
||||
// 2. from the CV-scraped version
|
||||
// Sliding panel content mapping
|
||||
const renderSlidingPanelContent = () => {
|
||||
switch (slidingPanelContentId) {
|
||||
case "CVMatches":
|
||||
return (
|
||||
<CVMatchesPanel
|
||||
rawFileDetails={rawFileDetails}
|
||||
inferredMetadata={inferredMetadata}
|
||||
comicVineMatches={comicVineMatches}
|
||||
comicObjectId={comicObjectId || _id}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={() => {
|
||||
setVisible(false);
|
||||
setActiveTab(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "editComicBookMetadata":
|
||||
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mx-auto">
|
||||
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="section">
|
||||
{!isNil(data) && !isEmpty(data) && (
|
||||
<>
|
||||
@@ -401,7 +220,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
!isEmpty(rawFileDetails?.cover) && (
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
@@ -425,25 +244,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
</RawFileDetails>
|
||||
|
||||
{/* <Modal
|
||||
style={{ content: { marginTop: "2rem" } }}
|
||||
isOpen={modalIsOpen}
|
||||
onAfterOpen={afterOpenModal}
|
||||
onRequestClose={closeModal}
|
||||
contentLabel="Example Modal"
|
||||
>
|
||||
<button onClick={closeModal}>close</button>
|
||||
{extractedComicBook && (
|
||||
<ComicViewer
|
||||
pages={extractedComicBook}
|
||||
direction="ltr"
|
||||
className={{
|
||||
closeButton: "border: 1px solid red;",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -451,7 +251,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<StyledSlidingPanel
|
||||
@@ -460,8 +262,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
title={"Comic Vine Search Matches"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
{renderSlidingPanelContent()}
|
||||
</StyledSlidingPanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
|
||||
|
||||
export const ComicDetailContainer = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: comicBookDetailData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["comicBookMetadata"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicObjectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
{
|
||||
isError && <>Error</>;
|
||||
}
|
||||
{
|
||||
isLoading && <>Loading...</>;
|
||||
}
|
||||
return (
|
||||
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
|
||||
} = useGetComicByIdQuery(
|
||||
{ id: comicObjectId! },
|
||||
{ enabled: !!comicObjectId }
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props;
|
||||
const { comicvine } = useStore(
|
||||
useShallow((state) => ({
|
||||
comicvine: state.comicvine,
|
||||
@@ -19,6 +19,8 @@ export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
<MatchResult
|
||||
matchData={comicVineMatches}
|
||||
comicObjectId={comicObjectId}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={onMatchApplied}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,32 +1,127 @@
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { useStore } from "../../store";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
/**
|
||||
* @typedef {Object} DownloadProgressTickProps
|
||||
* @property {string} bundleId - The bundle ID to filter ticks by (as string)
|
||||
*/
|
||||
interface DownloadProgressTickProps {
|
||||
bundleId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the download tick data received over the socket.
|
||||
*
|
||||
* @typedef DownloadTickData
|
||||
* @property {number} id - Unique download ID
|
||||
* @property {string} name - File name (e.g. "movie.mkv")
|
||||
* @property {number} downloaded_bytes - Bytes downloaded so far
|
||||
* @property {number} size - Total size in bytes
|
||||
* @property {number} speed - Current download speed (bytes/sec)
|
||||
* @property {number} seconds_left - Estimated seconds remaining
|
||||
* @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status
|
||||
* - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`)
|
||||
* @property {{ online: number; total: number; str: string }} sources
|
||||
* - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`)
|
||||
* @property {string} target - Download destination (e.g. "/Downloads/movie.mkv")
|
||||
*/
|
||||
interface DownloadTickData {
|
||||
id: number;
|
||||
name: string;
|
||||
downloaded_bytes: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
seconds_left: number;
|
||||
status: {
|
||||
id: string;
|
||||
str: string;
|
||||
completed: boolean;
|
||||
downloaded: boolean;
|
||||
failed: boolean;
|
||||
hook_error: any;
|
||||
};
|
||||
sources: {
|
||||
online: number;
|
||||
total: number;
|
||||
str: string;
|
||||
};
|
||||
target: string;
|
||||
}
|
||||
|
||||
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||
bundleId,
|
||||
}): ReactElement | null => {
|
||||
const socketRef = useRef<Socket>();
|
||||
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.emit("call", "socket.listenFileProgress", {
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler for each "downloadTick" event.
|
||||
* Only update state if event.id matches bundleId.
|
||||
*
|
||||
* @param {DownloadTickData} data - Payload from the server
|
||||
*/
|
||||
const onDownloadTick = (data: DownloadTickData) => {
|
||||
// Compare numeric data.id to string bundleId
|
||||
if (data.id === parseInt(bundleId, 10)) {
|
||||
setTick(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("downloadTick", onDownloadTick);
|
||||
return () => {
|
||||
socket.off("downloadTick", onDownloadTick);
|
||||
};
|
||||
}, [socketRef, bundleId]);
|
||||
|
||||
if (!tick) {
|
||||
return <>Nothing detected.</>;
|
||||
}
|
||||
|
||||
// Compute human-readable values and percentages
|
||||
const downloaded = prettyBytes(tick.downloaded_bytes);
|
||||
const total = prettyBytes(tick.size);
|
||||
const percent = tick.size > 0
|
||||
? Math.round((tick.downloaded_bytes / tick.size) * 100)
|
||||
: 0;
|
||||
const speed = prettyBytes(tick.speed) + "/s";
|
||||
const minutesLeft = Math.round(tick.seconds_left / 60);
|
||||
|
||||
export const DownloadProgressTick = (props): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="is-size-5">{props.data.name}</h4>
|
||||
<div>
|
||||
<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 className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||
{/* Downloaded vs Total */}
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||
</div>
|
||||
|
||||
<div>{props.data.target}</div>
|
||||
{/* Progress bar */}
|
||||
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-green-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||
import { TorrentDownloads } from "./TorrentDownloads";
|
||||
@@ -9,47 +8,73 @@ import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
TORRENT_JOB_SERVICE_BASE_URI,
|
||||
SOCKET_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface IDownloadsPanelProps {
|
||||
key: number;
|
||||
export interface TorrentDetails {
|
||||
infoHash: string;
|
||||
progress: number;
|
||||
downloadSpeed?: number;
|
||||
uploadSpeed?: number;
|
||||
}
|
||||
|
||||
export const DownloadsPanel = (
|
||||
props: IDownloadsPanelProps,
|
||||
): ReactElement | null => {
|
||||
/**
|
||||
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||
* - DC++ (AirDCPP) bundles
|
||||
* - Torrent downloads
|
||||
* It also listens for real-time torrent updates via a WebSocket.
|
||||
*
|
||||
* @component
|
||||
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
|
||||
*/
|
||||
export const DownloadsPanel = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||
const [torrentDetails, setTorrentDetails] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("directconnect");
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||
"directconnect",
|
||||
);
|
||||
|
||||
// React to torrent progress data sent over websockets
|
||||
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
|
||||
const torrents = data.torrents
|
||||
.flatMap(({ _id, details }) => {
|
||||
if (_id === comicObjectId) {
|
||||
return details;
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== undefined);
|
||||
setTorrentDetails(torrents);
|
||||
});
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Query to fetch AirDC++ download bundles for a given comic resource Id
|
||||
* @param {string} {comicObjectId} - A mongo id that identifies a comic document
|
||||
* Registers socket listeners on mount and cleans up on unmount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socketIOInstance) return;
|
||||
|
||||
/**
|
||||
* Handler for incoming torrent data events.
|
||||
* Merges new entries or updates existing ones by infoHash.
|
||||
*
|
||||
* @param {TorrentDetails} data - Payload from the socket event.
|
||||
*/
|
||||
const handleTorrentData = (data: TorrentDetails) => {
|
||||
setTorrentDetails((prev) => {
|
||||
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
|
||||
if (idx === -1) {
|
||||
return [...prev, data];
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
|
||||
|
||||
return () => {
|
||||
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
|
||||
};
|
||||
}, [socketIOInstance]);
|
||||
|
||||
// ————— DC++ Bundles (via REST) —————
|
||||
const { data: bundles } = useQuery({
|
||||
queryKey: ["bundles"],
|
||||
queryKey: ["bundles", comicObjectId],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||
@@ -64,77 +89,66 @@ export const DownloadsPanel = (
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: activeTab !== "" && activeTab === "directconnect",
|
||||
});
|
||||
|
||||
// Call the scheduled job for fetching torrent data
|
||||
// triggered by the active tab been set to "torrents"
|
||||
const { data: torrentData } = useQuery({
|
||||
queryFn: () =>
|
||||
axios({
|
||||
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||
method: "GET",
|
||||
params: {
|
||||
trigger: activeTab,
|
||||
},
|
||||
}),
|
||||
queryKey: [activeTab],
|
||||
enabled: activeTab !== "" && activeTab === "torrents",
|
||||
// ————— 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",
|
||||
});
|
||||
console.log(bundles);
|
||||
|
||||
// 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="columns is-multiline">
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="Download Type" className="sr-only">
|
||||
Download Type
|
||||
</label>
|
||||
<>
|
||||
<div className="mt-5 mb-3">
|
||||
<nav className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
DC++
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "torrents"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Torrents
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<select id="Tab" className="w-full rounded-md border-gray-200">
|
||||
<option>DC++ Downloads</option>
|
||||
<option>Torrents</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
>
|
||||
DC++ Downloads
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
||||
activeTab === "torrents"
|
||||
? "bg-slate-200 text-slate-800"
|
||||
: "dark:text-slate-400 text-slate-800"
|
||||
}`}
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
>
|
||||
Torrents
|
||||
</a>
|
||||
</nav>
|
||||
<div 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>
|
||||
|
||||
{activeTab === "torrents" ? (
|
||||
<TorrentDownloads data={torrentDetails} />
|
||||
) : null}
|
||||
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
|
||||
<AirDCPPBundles data={bundles.data} />
|
||||
) : (
|
||||
"nutin"
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsPanel;
|
||||
|
||||
@@ -8,6 +8,8 @@ import axios from "axios";
|
||||
interface MatchResultProps {
|
||||
matchData: any;
|
||||
comicObjectId: string;
|
||||
queryClient?: any;
|
||||
onMatchApplied?: () => void;
|
||||
}
|
||||
|
||||
const handleBrokenImage = (e) => {
|
||||
@@ -16,14 +18,33 @@ const handleBrokenImage = (e) => {
|
||||
|
||||
export const MatchResult = (props: MatchResultProps) => {
|
||||
const applyCVMatch = async (match, comicObjectId) => {
|
||||
return await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate and refetch the comic book metadata
|
||||
if (props.queryClient) {
|
||||
await props.queryClient.invalidateQueries({
|
||||
queryKey: ["comicBookMetadata", comicObjectId],
|
||||
});
|
||||
}
|
||||
|
||||
// Call the callback to close panel and switch tabs
|
||||
if (props.onMatchApplied) {
|
||||
props.onMatchApplied();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error applying ComicVine match:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
||||
|
||||
interface RawFileDetailsProps {
|
||||
type RawFileDetailsProps = {
|
||||
data?: {
|
||||
rawFileDetails?: {
|
||||
containedIn?: string;
|
||||
name?: string;
|
||||
fileSize?: number;
|
||||
path?: string;
|
||||
extension?: string;
|
||||
mimeType?: string;
|
||||
cover?: {
|
||||
filePath?: string;
|
||||
};
|
||||
};
|
||||
rawFileDetails?: RawFileDetailsType;
|
||||
inferredMetadata?: {
|
||||
issue?: {
|
||||
year?: string;
|
||||
@@ -27,18 +18,18 @@ interface RawFileDetailsProps {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
children?: any;
|
||||
}
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data;
|
||||
props.data || {};
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-2xl ml-5">
|
||||
<div className="px-4 sm:px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xl">{rawFileDetails.name}</span>
|
||||
<span className="text-xl">{rawFileDetails?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
@@ -48,10 +39,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
{rawFileDetails?.containedIn}
|
||||
{"/"}
|
||||
{rawFileDetails?.name}
|
||||
{rawFileDetails?.extension}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
@@ -59,10 +50,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
Inferred Issue Metadata
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
Series Name: {inferredMetadata.issue.name}
|
||||
{!isEmpty(inferredMetadata.issue.number) ? (
|
||||
Series Name: {inferredMetadata?.issue?.name}
|
||||
{!isEmpty(inferredMetadata?.issue?.number) ? (
|
||||
<span className="tag is-primary is-light">
|
||||
{inferredMetadata.issue.number}
|
||||
{inferredMetadata?.issue?.number}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
@@ -79,7 +70,7 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
{rawFileDetails?.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
@@ -96,7 +87,7 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
@@ -106,8 +97,12 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(created_at), "h aaaa")}
|
||||
{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">
|
||||
|
||||
65
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
65
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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} />;
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import React, { ReactElement, Suspense, useState } from "react";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const TabControls = (props): ReactElement => {
|
||||
const { filteredTabs, downloadCount } = props;
|
||||
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
||||
const [active, setActive] = useState(filteredTabs[0].id);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const currentActive = activeTab !== undefined ? activeTab : active;
|
||||
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -14,12 +18,12 @@ export const TabControls = (props): ReactElement => {
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
currentActive === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
onClick={() => handleSetActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<>
|
||||
@@ -43,9 +47,11 @@ export const TabControls = (props): ReactElement => {
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
})}
|
||||
<Suspense>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return currentActive === id ? content : null;
|
||||
})}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,34 +14,41 @@ import { useStore } from "../../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
||||
|
||||
export const ArchiveOperations = (props): ReactElement => {
|
||||
export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
||||
const { data } = props;
|
||||
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
const getSocket = useStore((state) => state.getSocket);
|
||||
const queryClient = useQueryClient();
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
// current image
|
||||
const [currentImage, setCurrentImage] = useState([]);
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
|
||||
const [currentImage, setCurrentImage] = useState<string>("");
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
|
||||
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||
useState(false);
|
||||
const constructImagePaths = (data): Array<string> => {
|
||||
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
|
||||
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
|
||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||
});
|
||||
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;
|
||||
@@ -58,7 +65,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
},
|
||||
transformResponse: async (responseData) => {
|
||||
const parsedData = JSON.parse(responseData);
|
||||
const paths = parsedData.map((pathObject) => {
|
||||
const paths = parsedData.map((pathObject: any) => {
|
||||
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
|
||||
});
|
||||
const uncompressedArchive = constructImagePaths(paths);
|
||||
@@ -70,8 +77,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching uncompressed archive:", error);
|
||||
// Handle error if necessary
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
fetchUncompressedArchive();
|
||||
@@ -131,7 +137,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
}
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
||||
imageAnalysis: {
|
||||
content: () => {
|
||||
return (
|
||||
@@ -143,7 +149,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
</pre>
|
||||
) : null}
|
||||
<pre className="font-hasklig mt-3 text-sm">
|
||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -152,7 +158,7 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
};
|
||||
|
||||
// sliding panel handlers
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||
setSlidingPanelContentId("imageAnalysis");
|
||||
analyzeImage(imageFilePath);
|
||||
setCurrentImage(imageFilePath);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { isUndefined } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const ComicInfoXML = (data): ReactElement => {
|
||||
export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
||||
const { json } = data;
|
||||
return (
|
||||
<div className="flex md:w-4/5 lg:w-78">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||
<div className="flex w-3/4">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
|
||||
<dt>
|
||||
<p className="text-lg">{json.series[0]}</p>
|
||||
<p className="text-lg">{json.series?.[0]}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
published by{" "}
|
||||
<span className="underline">
|
||||
{json.publisher[0]}
|
||||
{json.publisher?.[0]}
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
</dd>
|
||||
@@ -30,18 +30,20 @@ export const ComicInfoXML = (data): ReactElement => {
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
<dd className="my-2">
|
||||
{/* Genre */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{/* Genre */}
|
||||
{!isUndefined(json.genre) && (
|
||||
<dd className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</dd>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<dd className="my-1">
|
||||
@@ -52,12 +54,14 @@ export const ComicInfoXML = (data): ReactElement => {
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dd>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
{!isUndefined(json.notes) && (
|
||||
<dd>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import prettyBytes from "pretty-bytes";
|
||||
|
||||
export const TorrentDownloads = (props) => {
|
||||
const { data } = props;
|
||||
console.log(Object.values(data));
|
||||
return (
|
||||
<>
|
||||
{data.map(({ torrent }) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const TorrentSearchPanel = (props) => {
|
||||
mutationFn: async (newTorrent) =>
|
||||
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
||||
onSuccess: async (data) => {
|
||||
console.log(data);
|
||||
// Torrent added successfully
|
||||
},
|
||||
});
|
||||
const searchIndexer = (values) => {
|
||||
|
||||
65
src/client/components/ComicDetail/actionMenuConfig.tsx
Normal file
65
src/client/components/ComicDetail/actionMenuConfig.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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)",
|
||||
}),
|
||||
};
|
||||
129
src/client/components/ComicDetail/tabConfig.tsx
Normal file
129
src/client/components/ComicDetail/tabConfig.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
89
src/client/components/ComicDetail/useComicVineMatching.ts
Normal file
89
src/client/components/ComicDetail/useComicVineMatching.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,82 +1,72 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import ZeroState from "./ZeroState";
|
||||
import { RecentlyImported } from "./RecentlyImported";
|
||||
import { WantedComicsList } from "./WantedComicsList";
|
||||
import { VolumeGroups } from "./VolumeGroups";
|
||||
import { LibraryStatistics } from "./LibraryStatistics";
|
||||
import { PullList } from "./PullList";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import {
|
||||
useGetRecentComicsQuery,
|
||||
useGetWantedComicsQuery,
|
||||
useGetVolumeGroupsQuery,
|
||||
useGetLibraryStatisticsQuery
|
||||
} from "../../graphql/generated";
|
||||
|
||||
export const Dashboard = (): ReactElement => {
|
||||
const { data: recentComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: false },
|
||||
},
|
||||
comicStatus: "recent",
|
||||
},
|
||||
}),
|
||||
queryKey: ["recentComics"],
|
||||
});
|
||||
// Wanted Comics
|
||||
const { data: wantedComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions: {
|
||||
page: 0,
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: true, $ne: null },
|
||||
},
|
||||
},
|
||||
}),
|
||||
queryKey: ["wantedComics"],
|
||||
});
|
||||
const { data: volumeGroups } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["volumeGroups"],
|
||||
});
|
||||
// Use GraphQL for recent comics
|
||||
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
|
||||
{ limit: 5 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const { data: statistics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||
method: "GET",
|
||||
}),
|
||||
queryKey: ["libraryStatistics"],
|
||||
});
|
||||
// Wanted Comics - using GraphQL
|
||||
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery(
|
||||
{
|
||||
paginationOptions: {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
sort: '{"updatedAt": -1}'
|
||||
},
|
||||
predicate: '{"acquisition.source.wanted": true}'
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
}
|
||||
);
|
||||
|
||||
// Volume Groups - using GraphQL
|
||||
const { data: volumeGroupsData, error: volumeGroupsError } = useGetVolumeGroupsQuery(
|
||||
undefined,
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
// Library Statistics - using GraphQL
|
||||
const { data: statisticsData, error: statisticsError } = useGetLibraryStatisticsQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false
|
||||
}
|
||||
);
|
||||
|
||||
const recentComics = recentComicsData?.comics?.comics || [];
|
||||
const wantedComics = !wantedComicsError ? (wantedComicsData?.getComicBooks?.docs || []) : [];
|
||||
const volumeGroups = volumeGroupsData?.getComicBookGroups || [];
|
||||
const statistics = !statisticsError ? statisticsData?.getLibraryStatistics : undefined;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-full">
|
||||
<PullList />
|
||||
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||
{/* Wanted comics */}
|
||||
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||
{/* Library Statistics */}
|
||||
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||
{/* Volume groups */}
|
||||
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||
</div>
|
||||
<>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<PullList />
|
||||
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
|
||||
{/* Wanted comics */}
|
||||
<WantedComicsList comics={wantedComics} />
|
||||
{/* Library Statistics */}
|
||||
{statistics && <LibraryStatistics stats={statistics} />}
|
||||
{/* Volume groups */}
|
||||
<VolumeGroups volumeGroups={volumeGroups} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import React, { ReactElement } from "react";
|
||||
import { isEmpty, isUndefined, map } from "lodash";
|
||||
import Header from "../shared/Header";
|
||||
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
|
||||
|
||||
type LibraryStatisticsProps = {
|
||||
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
|
||||
};
|
||||
|
||||
export const LibraryStatistics = (
|
||||
props: ILibraryStatisticsProps,
|
||||
props: LibraryStatisticsProps,
|
||||
): ReactElement => {
|
||||
const { stats } = props;
|
||||
return (
|
||||
@@ -24,29 +28,30 @@ export const LibraryStatistics = (
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{props.stats.totalDocuments} files
|
||||
</dd>
|
||||
<dd>
|
||||
<span className="text-2xl text-green-600">
|
||||
{props.stats.comicDirectorySize &&
|
||||
prettyBytes(props.stats.comicDirectorySize)}
|
||||
</span>
|
||||
</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) &&
|
||||
!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">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics[0].issues.length}
|
||||
{props.stats.statistics?.[0]?.issues?.length || 0}
|
||||
</span>{" "}
|
||||
tagged with ComicVine
|
||||
</div>
|
||||
)}
|
||||
{!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">
|
||||
<span className="text-xl">
|
||||
{props.stats.statistics[0].issuesWithComicInfoXML.length}
|
||||
{props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0}
|
||||
</span>{" "}
|
||||
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||
with ComicInfo.xml
|
||||
@@ -57,14 +62,14 @@ export const LibraryStatistics = (
|
||||
|
||||
<div className="">
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
|
||||
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
|
||||
>
|
||||
{fileType.data.length} {fileType._id}
|
||||
{fileType.data.length} {fileType.id}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -75,20 +80,20 @@ export const LibraryStatistics = (
|
||||
{/* publisher with most issues */}
|
||||
{!isUndefined(props.stats.statistics) &&
|
||||
!isEmpty(
|
||||
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||
props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0],
|
||||
) && (
|
||||
<>
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0]._id
|
||||
props.stats.statistics?.[0]
|
||||
?.publisherWithMostComicsInLibrary?.[0]?.id
|
||||
}
|
||||
</span>
|
||||
{" has the most issues "}
|
||||
<span className="">
|
||||
{
|
||||
props.stats.statistics[0]
|
||||
.publisherWithMostComicsInLibrary[0].count
|
||||
props.stats.statistics?.[0]
|
||||
?.publisherWithMostComicsInLibrary?.[0]?.count
|
||||
}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -2,73 +2,86 @@ import React, { ReactElement, useState } from "react";
|
||||
import { map } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import { importToDB } from "../../actions/fileops.actions";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import { setupCache } from "axios-cache-interceptor";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import "keen-slider/keen-slider.min.css";
|
||||
import { useKeenSlider } from "keen-slider/react";
|
||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import { Form } from "react-final-form";
|
||||
import DatePickerDialog from "../shared/DatePicker";
|
||||
import { format } from "date-fns";
|
||||
import { LocgMetadata, useGetWeeklyPullListQuery } from "../../graphql/generated";
|
||||
|
||||
type PullListProps = {
|
||||
issues: any;
|
||||
};
|
||||
interface PullListProps {
|
||||
issues?: LocgMetadata[];
|
||||
}
|
||||
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
maxRPS: 1,
|
||||
});
|
||||
const cachedAxios = setupCache(axios);
|
||||
export const PullList = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// datepicker
|
||||
const date = new Date();
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
format(date, "M-dd-yyyy"),
|
||||
format(date, "yyyy/M/dd"),
|
||||
);
|
||||
|
||||
// keen slider
|
||||
const [sliderRef, instanceRef] = useKeenSlider(
|
||||
{
|
||||
loop: true,
|
||||
slides: {
|
||||
origin: "auto",
|
||||
number: 15,
|
||||
perView: 5,
|
||||
spacing: 15,
|
||||
},
|
||||
slideChanged() {
|
||||
console.log("slide changed");
|
||||
},
|
||||
},
|
||||
[
|
||||
// add plugins here
|
||||
],
|
||||
);
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
const {
|
||||
data: pullList,
|
||||
data: pullListData,
|
||||
refetch,
|
||||
isSuccess,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryFn: async (): any =>
|
||||
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||
method: "get",
|
||||
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
|
||||
}),
|
||||
queryKey: ["pullList", inputValue],
|
||||
} = useGetWeeklyPullListQuery({
|
||||
input: {
|
||||
startDate: inputValue,
|
||||
pageSize: 15,
|
||||
currentPage: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Transform the data to match the old structure
|
||||
const pullList = pullListData ? { data: pullListData.getWeeklyPullList } : undefined;
|
||||
|
||||
const { mutate: addToLibrary } = useMutation({
|
||||
mutationFn: async ({ sourceName, metadata }: { sourceName: string; metadata: any }) => {
|
||||
const comicBookMetadata = {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata: metadata || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
},
|
||||
};
|
||||
|
||||
return await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||
method: "POST",
|
||||
data: comicBookMetadata,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch wanted comics queries
|
||||
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||
},
|
||||
});
|
||||
const addToLibrary = (sourceName: string, locgMetadata) =>
|
||||
importToDB(sourceName, { locg: locgMetadata });
|
||||
|
||||
const next = () => {
|
||||
// sliderRef.slickNext();
|
||||
@@ -79,15 +92,14 @@ export const PullList = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
<Header
|
||||
<Header
|
||||
headerContent="Discover"
|
||||
subHeaderContent={
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://www.tfaw.com/comics/new-releases.html">
|
||||
Things From Another World
|
||||
<a href="https://leagueofcomicgeeks.com">
|
||||
League Of Comic Geeks
|
||||
</a>
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
@@ -123,43 +135,46 @@ export const PullList = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuccess && !isLoading && (
|
||||
<div ref={sliderRef} className="keen-slider flex flex-row">
|
||||
{map(pullList?.data.result, (issue, idx) => {
|
||||
return (
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.coverImageUrl}
|
||||
hasDetails
|
||||
title={ellipsize(issue.name, 25)}
|
||||
>
|
||||
<div className="px-1">
|
||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
{issue.publicationDate}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => addToLibrary("locg", issue)}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
||||
Want
|
||||
</button>
|
||||
</div>
|
||||
<div 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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? <div>Loading...</div> : null}
|
||||
{isError ? (
|
||||
<div>An error occurred while retrieving the pull list.</div>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{isError && <div>An error occurred while retrieving the pull list.</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,21 +4,27 @@ import { Link } from "react-router-dom";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import {
|
||||
determineCoverFile,
|
||||
determineExternalMetadata,
|
||||
} from "../../shared/utils/metadata.utils";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { GetRecentComicsQuery } from "../../graphql/generated";
|
||||
|
||||
type RecentlyImportedProps = {
|
||||
comics: any;
|
||||
comics: GetRecentComicsQuery['comics']['comics'];
|
||||
};
|
||||
|
||||
export const RecentlyImported = (
|
||||
comics: RecentlyImportedProps,
|
||||
{ comics }: RecentlyImportedProps,
|
||||
): ReactElement => {
|
||||
console.log(comics);
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
@@ -26,44 +32,50 @@ export const RecentlyImported = (
|
||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{comics?.comics.map(
|
||||
(
|
||||
{
|
||||
_id,
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{comics?.map((comic, idx) => {
|
||||
const {
|
||||
id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
sourcedMetadata,
|
||||
canonicalMetadata,
|
||||
inferredMetadata,
|
||||
wanted: { source } = {},
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
} = comic;
|
||||
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicvine)
|
||||
: sourcedMetadata?.comicvine;
|
||||
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicInfo)
|
||||
: sourcedMetadata?.comicInfo;
|
||||
const locg = sourcedMetadata?.locg;
|
||||
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||
source,
|
||||
{
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
},
|
||||
);
|
||||
const isComicVineMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
|
||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
||||
const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
||||
return (
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
<div
|
||||
key={idx}
|
||||
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||
title={inferredMetadata.issue.name}
|
||||
hasDetails
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
imageUrl={url}
|
||||
title={inferredMetadata?.issue?.name}
|
||||
hasDetails
|
||||
cardState={cardState}
|
||||
>
|
||||
<div>
|
||||
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||
{/* Issue number */}
|
||||
@@ -72,7 +84,7 @@ export const RecentlyImported = (
|
||||
<i className="icon-[solar--hashtag-outline]"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
{inferredMetadata?.issue?.number}
|
||||
</span>
|
||||
</span>
|
||||
{/* File extension */}
|
||||
@@ -82,7 +94,7 @@ export const RecentlyImported = (
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.extension}
|
||||
{rawFileDetails?.extension}
|
||||
</span>
|
||||
</span>
|
||||
{/* Uncompressed status */}
|
||||
@@ -100,16 +112,17 @@ export const RecentlyImported = (
|
||||
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
||||
{/* ComicInfo.xml presence */}
|
||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||
<div mt-1>
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||
<div className="mt-1">
|
||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-gray-500 dark:text-white-300"></i>
|
||||
</div>
|
||||
)}
|
||||
{/* ComicVine metadata presence */}
|
||||
{isComicVineMetadataAvailable && (
|
||||
<span className="w-7 h-7">
|
||||
<span className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@@ -122,9 +135,11 @@ export const RecentlyImported = (
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,59 +4,79 @@ import ellipsize from "ellipsize";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { GetVolumeGroupsQuery } from "../../graphql/generated";
|
||||
|
||||
export const VolumeGroups = (props): ReactElement => {
|
||||
type VolumeGroupsProps = {
|
||||
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
||||
};
|
||||
|
||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
|
||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||
const navigate = useNavigate();
|
||||
const navigateToVolumes = (row) => {
|
||||
const navigateToVolumes = (row: any) => {
|
||||
navigate(`/volumes/all`);
|
||||
};
|
||||
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
<Header
|
||||
headerContent="Volumes"
|
||||
subHeaderContent="Based on ComicVine Volume information"
|
||||
subHeaderContent={<>Based on ComicVine Volume information</>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/volumes"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(deduplicatedGroups, (data) => {
|
||||
return (
|
||||
<div className="max-w-sm mx-auto" key={data._id}>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
key={data._id}
|
||||
imageUrl={data.volumes.image.small_url}
|
||||
hasDetails
|
||||
>
|
||||
<div className="py-3">
|
||||
<div className="text-sm">
|
||||
<Link to={`/volume/details/${data._id}`}>
|
||||
{ellipsize(data.volumes.name, 48)}
|
||||
</Link>
|
||||
</div>
|
||||
{/* issue count */}
|
||||
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{map(deduplicatedGroups, (data) => {
|
||||
return (
|
||||
<div
|
||||
key={data.id}
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation="vertical-2"
|
||||
imageUrl={data.volumes?.image?.small_url || undefined}
|
||||
hasDetails
|
||||
>
|
||||
<div className="py-3">
|
||||
<div className="text-sm">
|
||||
<Link to={`/volume/details/${data.id}`}>
|
||||
{ellipsize(data.volumes?.name || 'Unknown', 48)}
|
||||
</Link>
|
||||
</div>
|
||||
{/* issue count */}
|
||||
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{data.volumes.count_of_issues} issues
|
||||
</span>
|
||||
</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>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.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 = {
|
||||
comics: any;
|
||||
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
|
||||
};
|
||||
|
||||
export const WantedComicsList = ({
|
||||
@@ -16,107 +18,119 @@ export const WantedComicsList = ({
|
||||
}: WantedComicsListProps): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// embla carousel
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
loop: false,
|
||||
align: "start",
|
||||
containScroll: "trimSnaps",
|
||||
slidesToScroll: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Header
|
||||
headerContent="Wanted Comics"
|
||||
subHeaderContent="Comics marked as wanted from various sources"
|
||||
subHeaderContent={<>Comics marked as wanted from various sources</>}
|
||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||
link={"/wanted"}
|
||||
/>
|
||||
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||
{map(
|
||||
comics,
|
||||
({
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
wanted,
|
||||
}) => {
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{map(
|
||||
comics,
|
||||
(comic) => {
|
||||
const {
|
||||
id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata,
|
||||
} = comic;
|
||||
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={_id}
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine.description)) ? (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
// Parse sourced metadata (GraphQL returns as strings)
|
||||
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicvine)
|
||||
: sourcedMetadata?.comicvine;
|
||||
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
||||
? JSON.parse(sourcedMetadata.comicInfo)
|
||||
: sourcedMetadata?.comicInfo;
|
||||
const locg = sourcedMetadata?.locg;
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(comicvine.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
};
|
||||
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||
>
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={url}
|
||||
hasDetails
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
cardState="wanted"
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine?.description)) ? (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(comicvine?.description)
|
||||
?.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Wanted comics - info not available in current GraphQL query */}
|
||||
</div>
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(locg) && (
|
||||
<img
|
||||
src="/src/client/assets/img/locglogo.svg"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
{/* issues marked as wanted, part of this volume */}
|
||||
{wanted?.markEntireVolumeWanted ? (
|
||||
<div className="text-sm">sagla volume pahije</div>
|
||||
) : (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{wanted.issues.length}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
alt={"ComicVine metadata detected."}
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(locg) && (
|
||||
<img
|
||||
src="/src/client/assets/img/locglogo.svg"
|
||||
className="w-7 h-7"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,13 +57,12 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
|
||||
}, [issueBundles]);
|
||||
|
||||
return !isNil(bundles) ? (
|
||||
<div className="container">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<section className="section">
|
||||
<h1 className="title">Downloads</h1>
|
||||
<div className="columns">
|
||||
<div className="column is-half">
|
||||
{bundles.map((bundle, idx) => {
|
||||
console.log(bundle);
|
||||
return (
|
||||
<div key={idx}>
|
||||
<MetadataPanel
|
||||
|
||||
493
src/client/components/Import/Import.test.tsx
Normal file
493
src/client/components/Import/Import.test.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
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 {};
|
||||
@@ -1,75 +1,215 @@
|
||||
import React, { ReactElement, useCallback, useEffect } from "react";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import Loader from "react-loader-spinner";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import axios from "axios";
|
||||
import {
|
||||
useGetJobResultStatisticsQuery,
|
||||
useGetImportStatisticsQuery,
|
||||
useStartIncrementalImportMutation
|
||||
} from "../../graphql/generated";
|
||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
||||
|
||||
interface IProps {
|
||||
matches?: unknown;
|
||||
fetchComicMetadata?: any;
|
||||
interface ImportProps {
|
||||
path: string;
|
||||
covers?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to facilitate the import of comics to the ThreeTwo library
|
||||
*
|
||||
* @param x - The first input number
|
||||
* @param y - The second input number
|
||||
* @returns The arithmetic mean of `x` and `y`
|
||||
*
|
||||
* @beta
|
||||
* Import component for adding comics to the ThreeTwo library.
|
||||
* Provides preview statistics, smart import, and queue management.
|
||||
*/
|
||||
|
||||
export const Import = (props: IProps): ReactElement => {
|
||||
export const Import = (props: ImportProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const { importJobQueue, socketIOInstance } = useStore(
|
||||
const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
importJobQueue: state.importJobQueue,
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
getSocket: state.getSocket,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
})),
|
||||
);
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
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 { mutate: initiateImport } = useMutation({
|
||||
mutationFn: async () =>
|
||||
await axios.request({
|
||||
mutationFn: async () => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
return await axios.request({
|
||||
url: `http://localhost:3000/api/library/newImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError, isLoading } = useQuery({
|
||||
queryKey: ["allImportJobResults"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
method: "GET",
|
||||
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
|
||||
}),
|
||||
// Force re-import mutation - re-imports all files regardless of import status
|
||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
return await axios.request({
|
||||
url: `http://localhost:3000/api/library/forceReImport`,
|
||||
method: "POST",
|
||||
data: { sessionId },
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
console.log("Force re-import initiated:", response.data);
|
||||
importJobQueue.setStatus("running");
|
||||
setImportError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Failed to start force re-import:", error);
|
||||
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
||||
|
||||
// Get import statistics to determine if Start Import button should be shown
|
||||
const { data: importStats } = useGetImportStatisticsQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Use custom hook for definitive import session status tracking
|
||||
// NO POLLING - relies on Socket.IO events only
|
||||
const importSession = useImportSessionStatus();
|
||||
|
||||
const hasActiveSession = importSession.isActive;
|
||||
|
||||
// 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) => {
|
||||
socketIOInstance.emit(
|
||||
const socket = getSocket("/");
|
||||
socket.emit(
|
||||
"call",
|
||||
"socket.setQueueStatus",
|
||||
{
|
||||
queueAction,
|
||||
queueStatus,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to render import job queue pause/resume controls on the UI
|
||||
*
|
||||
* @param status The `string` status (either `"pause"` or `"resume"`)
|
||||
* @returns ReactElement A `<button/>` that toggles queue status
|
||||
* @remarks Sets the global `importJobQueue.status` state upon toggling
|
||||
* Starts smart import with race condition prevention
|
||||
*/
|
||||
const handleStartSmartImport = async () => {
|
||||
// Clear any previous errors
|
||||
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 (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 => {
|
||||
switch (status) {
|
||||
@@ -115,11 +255,12 @@ export const Import = (props: IProps): ReactElement => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
@@ -154,70 +295,104 @@ export const Import = (props: IProps): ReactElement => {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="my-4">
|
||||
{importJobQueue.status === "drained" ||
|
||||
(importJobQueue.status === undefined && (
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-5 py-3 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => {
|
||||
initiateImport();
|
||||
importJobQueue.setStatus("running");
|
||||
}}
|
||||
>
|
||||
<span className="text-md">Start Import</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Import Statistics */}
|
||||
<div className="my-6 max-w-screen-lg">
|
||||
<RealTimeImportStats />
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
{(importJobQueue.status === "running" ||
|
||||
importJobQueue.status === "paused") && (
|
||||
<>
|
||||
<span className="flex items-center my-5 max-w-screen-lg">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Import Activity
|
||||
{/* 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>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
|
||||
{/* Successful import counts */}
|
||||
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||
{importJobQueue.successfulJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
imported
|
||||
</dt>
|
||||
</div>
|
||||
{/* Failed job counts */}
|
||||
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
|
||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||
{importJobQueue.failedJobCount}
|
||||
</dd>
|
||||
<dt className="text-lg font-medium text-gray-500">
|
||||
failed
|
||||
</dt>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col dark:text-slate-200 text-slate-400">
|
||||
<dd>{renderQueueControls(importJobQueue.status)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div 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 className="flex">
|
||||
<span className="mt-2 dark:text-slate-200 text-slate-400">
|
||||
Imported: <span>{importJobQueue.mostRecentImport}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past imports */}
|
||||
{!isLoading && !isEmpty(data?.data) && (
|
||||
{/* 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">
|
||||
@@ -230,6 +405,9 @@ export const Import = (props: IProps): ReactElement => {
|
||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
||||
<thead className="ltr:text-left rtl:text-right">
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
#
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||
Time Started
|
||||
</th>
|
||||
@@ -246,14 +424,19 @@ export const Import = (props: IProps): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data?.data.map((jobResult, id) => {
|
||||
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
|
||||
return (
|
||||
<tr key={id}>
|
||||
<tr key={index}>
|
||||
<td className="whitespace-nowrap px-4 py-2 text-gray-700 dark:text-slate-300 font-medium">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
{format(
|
||||
new Date(jobResult.earliestTimestamp),
|
||||
"EEEE, hh:mma, do LLLL Y",
|
||||
)}
|
||||
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
|
||||
? format(
|
||||
new Date(parseInt(jobResult.earliestTimestamp)),
|
||||
"EEEE, hh:mma, do LLLL y",
|
||||
)
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||
<span className="tag is-warning">
|
||||
|
||||
231
src/client/components/Import/RealTimeImportStats.tsx
Normal file
231
src/client/components/Import/RealTimeImportStats.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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;
|
||||
@@ -83,30 +83,30 @@ export const Library = (): ReactElement => {
|
||||
|
||||
const ComicInfoXML = (value) => {
|
||||
return value.data ? (
|
||||
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
|
||||
<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">
|
||||
{/* Series Name */}
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{ellipsize(value.data.series[0], 45)}
|
||||
<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 mt-2 gap-2">
|
||||
<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-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
<span 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-md text-slate-900 dark:text-slate-900">
|
||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
||||
Pages: {value.data.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
{/* Issue number */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{!isNil(value.data.number) && (
|
||||
@@ -128,7 +128,7 @@ export const Library = (): ReactElement => {
|
||||
{
|
||||
header: "File Details",
|
||||
id: "fileDetails",
|
||||
minWidth: 400,
|
||||
minWidth: 250,
|
||||
accessorKey: "_source",
|
||||
cell: (info) => {
|
||||
return <MetadataPanel data={info.getValue()} />;
|
||||
@@ -248,7 +248,7 @@ export const Library = (): ReactElement => {
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
@@ -263,7 +263,7 @@ export const Library = (): ReactElement => {
|
||||
</div>
|
||||
</header>
|
||||
{!isUndefined(searchResults?.hits) ? (
|
||||
<div>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div>
|
||||
<T2Table
|
||||
totalPages={searchResults.hits.total.value}
|
||||
|
||||
@@ -72,8 +72,11 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
||||
>
|
||||
<div className="content is-flex is-flex-direction-row">
|
||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||
<span className="icon cv-icon is-small">
|
||||
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||
<img
|
||||
src="/src/client/assets/img/cvlogo.svg"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isNil(rawFileDetails) && (
|
||||
|
||||
@@ -99,7 +99,7 @@ export const PullList = (): ReactElement => {
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<section className="container">
|
||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="section">
|
||||
<div className="header-area">
|
||||
<h1 className="title">Weekly Pull List</h1>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import "../../shared/utils/i18n.util";
|
||||
import PopoverButton from "../shared/PopoverButton";
|
||||
import dayjs from "dayjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
@@ -20,6 +20,7 @@ import axios from "axios";
|
||||
interface ISearchProps {}
|
||||
|
||||
export const Search = ({}: ISearchProps): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const formData = {
|
||||
search: "",
|
||||
};
|
||||
@@ -77,7 +78,6 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
coverDate: cover_date,
|
||||
issueNumber: issue_number,
|
||||
});
|
||||
console.log(issues);
|
||||
// Get volume metadata from CV
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
||||
@@ -110,7 +110,6 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Invalid resource type.");
|
||||
break;
|
||||
}
|
||||
// Add to wanted list
|
||||
@@ -138,6 +137,10 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch wanted comics queries
|
||||
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
||||
},
|
||||
});
|
||||
|
||||
const addToLibrary = (sourceName: string, comicData) =>
|
||||
@@ -160,7 +163,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
<div>
|
||||
<section>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
AIRDCPP_SERVICE_BASE_URI,
|
||||
@@ -35,7 +35,6 @@ export const AirDCPPSettingsForm = () => {
|
||||
// Handle setting update and subsequent AirDC++ initialization
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (values) => {
|
||||
console.log(values);
|
||||
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: values,
|
||||
settingsKey: "directConnect",
|
||||
|
||||
37
src/client/components/Settings/DockerVars/DockerVars.tsx
Normal file
37
src/client/components/Settings/DockerVars/DockerVars.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
@@ -19,7 +19,6 @@ export const ProwlarrSettingsForm = (props) => {
|
||||
},
|
||||
queryKey: ["prowlarrConnectionResult"],
|
||||
});
|
||||
console.log(data);
|
||||
const submitHandler = () => {};
|
||||
const initialData = {};
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
|
||||
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
|
||||
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
|
||||
import ProwlarrSettingsForm from "./ProwlarrSettings/ProwlarrSettingsForm";
|
||||
import DockerVars from "./DockerVars/DockerVars";
|
||||
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
|
||||
import settingsObject from "../../constants/settings/settingsMenu.json";
|
||||
import { isUndefined, map } from "lodash";
|
||||
@@ -12,139 +13,130 @@ interface ISettingsProps {}
|
||||
|
||||
export const Settings = (props: ISettingsProps): ReactElement => {
|
||||
const [active, setActive] = useState("gen-db");
|
||||
console.log(active);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const settingsContent = [
|
||||
{
|
||||
id: "adc-hubs",
|
||||
content: (
|
||||
<div key="adc-hubs">
|
||||
<AirDCPPHubsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "adc-connection",
|
||||
content: (
|
||||
<div key="adc-connection">
|
||||
<AirDCPPSettingsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "qbt-connection",
|
||||
content: (
|
||||
<div key="qbt-connection">
|
||||
<QbittorrentConnectionForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "prwlr-connection",
|
||||
content: (
|
||||
<>
|
||||
<ProwlarrSettingsForm />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "core-service",
|
||||
content: <>a</>,
|
||||
},
|
||||
{
|
||||
id: "flushdb",
|
||||
content: (
|
||||
<div key="flushdb">
|
||||
<SystemSettingsForm />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: "adc-hubs", content: <AirDCPPHubsForm /> },
|
||||
{ id: "adc-connection", content: <AirDCPPSettingsForm /> },
|
||||
{id: "gen-docker-vars", content: <DockerVars />},
|
||||
{ id: "qbt-connection", content: <QbittorrentConnectionForm /> },
|
||||
{ id: "prwlr-connection", content: <ProwlarrSettingsForm /> },
|
||||
{ id: "core-service", content: <>a</> },
|
||||
{ id: "flushdb", content: <SystemSettingsForm /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
{/* Header */}
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-white">
|
||||
Import comics into the ThreeTwo library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-row">
|
||||
<div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
|
||||
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{map(settingsObject, (settingObject, idx) => {
|
||||
return (
|
||||
|
||||
{/* 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
|
||||
className="w-64 py-2 text-slate-700 dark:text-slate-400"
|
||||
key={idx}
|
||||
className="mb-6 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<h3 className="text-l pb-2">
|
||||
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
|
||||
{settingObject.category.toUpperCase()}
|
||||
</h3>
|
||||
{/* First level children */}
|
||||
{!isUndefined(settingObject.children) ? (
|
||||
<ul key={settingObject.id}>
|
||||
|
||||
{!isUndefined(settingObject.children) && (
|
||||
<ul>
|
||||
{map(settingObject.children, (item, idx) => {
|
||||
const isOpen = expanded[item.id];
|
||||
|
||||
return (
|
||||
<li key={idx} className="mb-2">
|
||||
<a
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() => setActive(item.id.toString())}
|
||||
<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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.displayName}
|
||||
</a>
|
||||
{/* Second level children */}
|
||||
{!isUndefined(item.children) ? (
|
||||
<ul className="pl-4 mt-2">
|
||||
{map(item.children, (item, idx) => (
|
||||
<li key={item.id} className="mb-2">
|
||||
<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
|
||||
className={
|
||||
item.id.toString() === active
|
||||
? "is-active flex items-center"
|
||||
: "flex items-center"
|
||||
}
|
||||
onClick={() =>
|
||||
setActive(item.id.toString())
|
||||
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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.displayName}
|
||||
{subItem.displayName}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* content for settings */}
|
||||
<div className="flex mx-12">
|
||||
<div className="">
|
||||
{map(settingsContent, ({ id, content }) =>
|
||||
active === id ? content : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<main className="flex-1 px-2 py-2">
|
||||
{settingsContent.map(({ id, content }) =>
|
||||
active === id ? <div key={id}>{content}</div> : null,
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +266,7 @@ const VolumeDetails = (props): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
@@ -280,7 +280,7 @@ const VolumeDetails = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="container mx-auto mt-4">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 mt-4">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
{/* Volume cover */}
|
||||
|
||||
@@ -33,7 +33,6 @@ export const Volumes = (props): ReactElement => {
|
||||
}),
|
||||
queryKey: ["volumes"],
|
||||
});
|
||||
console.log(volumes);
|
||||
const columnData = useMemo(
|
||||
(): any => [
|
||||
{
|
||||
@@ -46,7 +45,6 @@ export const Volumes = (props): ReactElement => {
|
||||
const {
|
||||
_source: { sourcedMetadata },
|
||||
} = comicObject;
|
||||
console.log("jaggu", row.getValue());
|
||||
return (
|
||||
<div className="flex flex-row gap-3 mt-5">
|
||||
<Link to={`/volume/details/${comicObject._id}`}>
|
||||
@@ -143,7 +141,7 @@ export const Volumes = (props): ReactElement => {
|
||||
<div>
|
||||
<section className="">
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
@@ -158,7 +156,7 @@ export const Volumes = (props): ReactElement => {
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess ? (
|
||||
<div>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={volumes?.data.hits.hits}
|
||||
|
||||
@@ -42,7 +42,6 @@ export const WantedComics = (props): ReactElement => {
|
||||
minWidth: 350,
|
||||
accessorFn: (data) => data,
|
||||
cell: (value) => {
|
||||
console.log("ASDASd", value);
|
||||
const row = value.getValue()._source;
|
||||
return row && <MetadataPanel data={row} />;
|
||||
},
|
||||
@@ -160,7 +159,7 @@ export const WantedComics = (props): ReactElement => {
|
||||
<div className="">
|
||||
<section className="">
|
||||
<header className="bg-slate-200 dark:bg-slate-500">
|
||||
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||
<div className="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">
|
||||
@@ -175,7 +174,7 @@ export const WantedComics = (props): ReactElement => {
|
||||
</div>
|
||||
</header>
|
||||
{isSuccess && wantedComics?.data.hits?.hits ? (
|
||||
<div>
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="library">
|
||||
<T2Table
|
||||
sourceData={wantedComics?.data.hits.hits}
|
||||
|
||||
@@ -10,11 +10,29 @@ interface ICardProps {
|
||||
children?: PropTypes.ReactNodeLike;
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported";
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: PropTypes.object;
|
||||
imageStyle?: PropTypes.object;
|
||||
cardContainerStyle?: React.CSSProperties;
|
||||
imageStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
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 => {
|
||||
switch (props.orientation) {
|
||||
case "horizontal":
|
||||
@@ -83,7 +101,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
|
||||
case "vertical-2":
|
||||
return (
|
||||
<div className="block rounded-md max-w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
||||
<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}
|
||||
@@ -109,7 +127,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
case "horizontal-small":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
|
||||
<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" />
|
||||
@@ -125,7 +143,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
case "horizontal-medium":
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
|
||||
<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} />
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DatePickerDialog = (props) => {
|
||||
const handleDaySelect = (date) => {
|
||||
setSelected(date);
|
||||
if (date) {
|
||||
setter(format(date, "yyyy-MM-dd"));
|
||||
setter(format(date, "yyyy/MM/dd"));
|
||||
apiAction();
|
||||
closePopper();
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
type IHeaderProps = {
|
||||
headerContent: string;
|
||||
subHeaderContent: ReactElement;
|
||||
subHeaderContent: ReactElement | string;
|
||||
iconClassNames: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
@@ -31,11 +31,11 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
{
|
||||
name: "rawFileDetails",
|
||||
content: () => (
|
||||
<dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg">
|
||||
<dl className="dark:bg-card-imported bg-card-imported dark:text-slate-800 p-2 sm:p-3 rounded-lg">
|
||||
<dt>
|
||||
<p className="text-lg">{issueName}</p>
|
||||
<p className="text-sm sm:text-lg">{issueName}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<dd className="text-xs sm:text-sm">
|
||||
is a part of{" "}
|
||||
<span className="underline">
|
||||
{inferredMetadata.issue.name}
|
||||
@@ -50,31 +50,31 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||
</span>
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
<dd className="flex flex-row gap-2 w-max">
|
||||
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
||||
{/* 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="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--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="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-5 h-5"></i>
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -179,14 +179,16 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex gap-5 my-3">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
<div>{metadataPanel.content()}</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
||||
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
imageStyle={props.imageStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">{metadataPanel.content()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,22 +3,22 @@ import { Link } from "react-router-dom";
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
||||
export const Navbar2 = (): ReactElement => {
|
||||
const [colorTheme, setTheme] = useDarkMode();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [theme, setTheme] = useDarkMode();
|
||||
const darkMode = theme === "dark";
|
||||
|
||||
const toggleDarkMode = (checked) => {
|
||||
setTheme(colorTheme);
|
||||
setDarkMode(!darkMode);
|
||||
const toggleDarkMode = () => {
|
||||
setTheme(darkMode ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-900 gap-8 px-5 py-5 h-18 border-b-2 border-gray-300 dark:border-slate-200">
|
||||
{/* Logo */}
|
||||
<div className="mx-auto flex">
|
||||
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
|
||||
<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">
|
||||
{/* Main Navigation */}
|
||||
<div className="flex flex-1 items-center justify-end md:justify-between">
|
||||
<nav
|
||||
aria-label="ThreeTwo Main Navigation"
|
||||
className="hidden md:block"
|
||||
@@ -79,48 +79,49 @@ export const Navbar2 = (): ReactElement => {
|
||||
</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">Dark</span>
|
||||
<label
|
||||
htmlFor="toggle"
|
||||
className="relative inline-flex items-center"
|
||||
{/* 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"
|
||||
>
|
||||
<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">Light</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
@@ -79,44 +79,49 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container max-w-fit mx-14">
|
||||
<div className="container max-w-fit">
|
||||
<div>
|
||||
<div className="flex flex-row gap-2 justify-between mt-7">
|
||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||
{/* Search bar */}
|
||||
{tableOptions.children}
|
||||
{/* pagination controls */}
|
||||
<div>
|
||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||
<p>{totalPages} comics in all</p>
|
||||
{/* Prev/Next buttons */}
|
||||
<div className="inline-flex flex-row mt-4 mb-4">
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||
<div className="mb-1">
|
||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||
{totalPages} comics in all
|
||||
</p>
|
||||
<div className="inline-flex flex-row mt-3">
|
||||
<button
|
||||
onClick={() => goToPreviousPage()}
|
||||
disabled={pageIndex === 1}
|
||||
className="dark:bg-slate-500 bg-slate-400 rounded-l border-slate-600 border-r pt-2 px-2"
|
||||
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-6 w-6"></i>
|
||||
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
|
||||
</button>
|
||||
<button
|
||||
className="dark:bg-slate-500 bg-slate-400 rounded-r pt-2 px-2"
|
||||
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||
onClick={() => goToNextPage()}
|
||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
||||
>
|
||||
<i className="icon-[solar--arrow-right-linear] h-6 w-6"></i>
|
||||
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table className="table-auto overflow-auto">
|
||||
<thead className="sticky top-0 bg-slate-200 dark:bg-slate-500">
|
||||
{table.getHeaderGroups().map((headerGroup, idx) => (
|
||||
|
||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900">
|
||||
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, idx) => (
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className="px-3 py-3"
|
||||
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
|
||||
? null
|
||||
@@ -131,22 +136,19 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row, idx) => {
|
||||
return (
|
||||
<tr key={row.id} onClick={() => rowClickHandler(row)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td key={cell.id} className="align-top">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{table.getRowModel().rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => rowClickHandler(row)}
|
||||
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"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2 align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"displayName": "Dashboard"
|
||||
},
|
||||
{
|
||||
"id": "gen-gls",
|
||||
"displayName": "Global Search"
|
||||
"id": "gen-docker-vars",
|
||||
"displayName": "Docker ENV vars"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
76
src/client/graphql/adapters/comicAdapter.ts
Normal file
76
src/client/graphql/adapters/comicAdapter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
43
src/client/graphql/fetcher.ts
Normal file
43
src/client/graphql/fetcher.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
2208
src/client/graphql/generated.ts
Normal file
2208
src/client/graphql/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
191
src/client/graphql/queries/comicDetail.graphql
Normal file
191
src/client/graphql/queries/comicDetail.graphql
Normal file
@@ -0,0 +1,191 @@
|
||||
query GetComicById($id: ID!) {
|
||||
comic(id: $id) {
|
||||
id
|
||||
|
||||
# Inferred metadata
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
|
||||
# Canonical metadata
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
series {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
volume {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
publisher {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
publicationDate {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
coverDate {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
description {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
creators {
|
||||
name
|
||||
role
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
}
|
||||
pageCount {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
coverImage {
|
||||
value
|
||||
provenance {
|
||||
source
|
||||
sourceId
|
||||
confidence
|
||||
fetchedAt
|
||||
url
|
||||
}
|
||||
userOverride
|
||||
}
|
||||
}
|
||||
|
||||
# Sourced metadata
|
||||
sourcedMetadata {
|
||||
comicInfo
|
||||
comicvine
|
||||
metron
|
||||
gcd
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
url
|
||||
cover
|
||||
description
|
||||
price
|
||||
rating
|
||||
pulls
|
||||
potw
|
||||
}
|
||||
}
|
||||
|
||||
# Raw file details
|
||||
rawFileDetails {
|
||||
name
|
||||
filePath
|
||||
fileSize
|
||||
extension
|
||||
mimeType
|
||||
containedIn
|
||||
pageCount
|
||||
archive {
|
||||
uncompressed
|
||||
expandedPath
|
||||
}
|
||||
cover {
|
||||
filePath
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
# Import status
|
||||
importStatus {
|
||||
isImported
|
||||
tagged
|
||||
matchedResult {
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
# Timestamps
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
217
src/client/graphql/queries/dashboard.graphql
Normal file
217
src/client/graphql/queries/dashboard.graphql
Normal file
@@ -0,0 +1,217 @@
|
||||
query GetComics($page: Int, $limit: Int, $search: String, $publisher: String, $series: String) {
|
||||
comics(page: $page, limit: $limit, search: $search, publisher: $publisher, series: $series) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
currentPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetRecentComics($limit: Int) {
|
||||
comics(page: 1, limit: $limit) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
publisher {
|
||||
value
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
query GetWantedComics($paginationOptions: PaginationOptionsInput!, $predicate: PredicateInput) {
|
||||
getComicBooks(paginationOptions: $paginationOptions, predicate: $predicate) {
|
||||
docs {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
extension
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalDocs
|
||||
limit
|
||||
page
|
||||
totalPages
|
||||
hasNextPage
|
||||
hasPrevPage
|
||||
}
|
||||
}
|
||||
|
||||
query GetVolumeGroups {
|
||||
getComicBookGroups {
|
||||
id
|
||||
volumes {
|
||||
id
|
||||
name
|
||||
count_of_issues
|
||||
publisher {
|
||||
id
|
||||
name
|
||||
}
|
||||
start_year
|
||||
image {
|
||||
small_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetLibraryStatistics {
|
||||
getLibraryStatistics {
|
||||
totalDocuments
|
||||
comicDirectorySize {
|
||||
fileCount
|
||||
}
|
||||
statistics {
|
||||
fileTypes {
|
||||
id
|
||||
data
|
||||
}
|
||||
issues {
|
||||
id {
|
||||
id
|
||||
name
|
||||
}
|
||||
data
|
||||
}
|
||||
fileLessComics {
|
||||
id
|
||||
}
|
||||
issuesWithComicInfoXML {
|
||||
id
|
||||
}
|
||||
publisherWithMostComicsInLibrary {
|
||||
id
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetWeeklyPullList($input: WeeklyPullListInput!) {
|
||||
getWeeklyPullList(input: $input) {
|
||||
result {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
src/client/graphql/queries/import.graphql
Normal file
60
src/client/graphql/queries/import.graphql
Normal file
@@ -0,0 +1,60 @@
|
||||
query GetImportStatistics($directoryPath: String) {
|
||||
getImportStatistics(directoryPath: $directoryPath) {
|
||||
success
|
||||
directory
|
||||
stats {
|
||||
totalLocalFiles
|
||||
alreadyImported
|
||||
newFiles
|
||||
percentageImported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation StartNewImport($sessionId: String!) {
|
||||
startNewImport(sessionId: $sessionId) {
|
||||
success
|
||||
message
|
||||
jobsQueued
|
||||
}
|
||||
}
|
||||
|
||||
mutation StartIncrementalImport($sessionId: String!, $directoryPath: String) {
|
||||
startIncrementalImport(sessionId: $sessionId, directoryPath: $directoryPath) {
|
||||
success
|
||||
message
|
||||
stats {
|
||||
total
|
||||
alreadyImported
|
||||
newFiles
|
||||
queued
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetJobResultStatistics {
|
||||
getJobResultStatistics {
|
||||
sessionId
|
||||
earliestTimestamp
|
||||
completedJobs
|
||||
failedJobs
|
||||
}
|
||||
}
|
||||
|
||||
query GetActiveImportSession {
|
||||
getActiveImportSession {
|
||||
sessionId
|
||||
type
|
||||
status
|
||||
startedAt
|
||||
completedAt
|
||||
directoryPath
|
||||
stats {
|
||||
totalFiles
|
||||
filesQueued
|
||||
filesProcessed
|
||||
filesSucceeded
|
||||
filesFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/client/graphql/queries/library.graphql
Normal file
72
src/client/graphql/queries/library.graphql
Normal file
@@ -0,0 +1,72 @@
|
||||
# Library queries
|
||||
# Note: The Library component currently uses Elasticsearch for search functionality
|
||||
# These queries are prepared for when the backend supports GraphQL-based library queries
|
||||
|
||||
query GetLibraryComics($page: Int, $limit: Int, $search: String, $series: String) {
|
||||
comics(page: $page, limit: $limit, search: $search, series: $series) {
|
||||
comics {
|
||||
id
|
||||
inferredMetadata {
|
||||
issue {
|
||||
name
|
||||
number
|
||||
year
|
||||
subtitle
|
||||
}
|
||||
}
|
||||
rawFileDetails {
|
||||
name
|
||||
filePath
|
||||
fileSize
|
||||
extension
|
||||
mimeType
|
||||
pageCount
|
||||
archive {
|
||||
uncompressed
|
||||
}
|
||||
cover {
|
||||
filePath
|
||||
}
|
||||
}
|
||||
sourcedMetadata {
|
||||
comicvine
|
||||
comicInfo
|
||||
locg {
|
||||
name
|
||||
publisher
|
||||
cover
|
||||
}
|
||||
}
|
||||
canonicalMetadata {
|
||||
title {
|
||||
value
|
||||
}
|
||||
series {
|
||||
value
|
||||
}
|
||||
issueNumber {
|
||||
value
|
||||
}
|
||||
publisher {
|
||||
value
|
||||
}
|
||||
pageCount {
|
||||
value
|
||||
}
|
||||
}
|
||||
importStatus {
|
||||
isImported
|
||||
tagged
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
currentPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,5 @@ export const useDarkMode = () => {
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme, colorTheme]);
|
||||
|
||||
return [colorTheme, setTheme];
|
||||
return [theme, setTheme];
|
||||
};
|
||||
|
||||
273
src/client/hooks/useImportSessionStatus.ts
Normal file
273
src/client/hooks/useImportSessionStatus.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useGetActiveImportSessionQuery } from "../graphql/generated";
|
||||
import { useStore } from "../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export type ImportSessionStatus =
|
||||
| "idle" // No import in progress
|
||||
| "running" // Import actively processing
|
||||
| "completed" // Import finished successfully
|
||||
| "failed" // Import finished with errors
|
||||
| "unknown"; // Unable to determine status
|
||||
|
||||
export interface ImportSessionState {
|
||||
status: ImportSessionStatus;
|
||||
sessionId: string | null;
|
||||
progress: number; // 0-100
|
||||
stats: {
|
||||
filesQueued: number;
|
||||
filesProcessed: number;
|
||||
filesSucceeded: number;
|
||||
filesFailed: number;
|
||||
} | null;
|
||||
isComplete: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to definitively determine import session completion status
|
||||
*
|
||||
* Uses Socket.IO events to trigger GraphQL refetches:
|
||||
* - IMPORT_SESSION_STARTED: New import started
|
||||
* - IMPORT_SESSION_UPDATED: Progress update
|
||||
* - IMPORT_SESSION_COMPLETED: Import finished
|
||||
* - LS_IMPORT_QUEUE_DRAINED: All jobs processed
|
||||
*
|
||||
* A session is considered DEFINITIVELY COMPLETE when:
|
||||
* - session.status === "completed" OR session.status === "failed"
|
||||
* - OR LS_IMPORT_QUEUE_DRAINED event is received AND no active session exists
|
||||
* - OR IMPORT_SESSION_COMPLETED event is received
|
||||
*
|
||||
* NO POLLING - relies entirely on Socket.IO events for real-time updates
|
||||
*/
|
||||
export const useImportSessionStatus = (): ImportSessionState => {
|
||||
|
||||
const [sessionState, setSessionState] = useState<ImportSessionState>({
|
||||
status: "unknown",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
const { getSocket } = useStore(
|
||||
useShallow((state) => ({
|
||||
getSocket: state.getSocket,
|
||||
}))
|
||||
);
|
||||
|
||||
// Track if we've received completion events
|
||||
const completionEventReceived = useRef(false);
|
||||
const queueDrainedEventReceived = useRef(false);
|
||||
|
||||
// Query active import session - NO POLLING, only refetch on Socket.IO events
|
||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
||||
{},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: false, // NO POLLING
|
||||
}
|
||||
);
|
||||
|
||||
const session = sessionData?.getActiveImportSession;
|
||||
|
||||
/**
|
||||
* Determine definitive completion status
|
||||
*/
|
||||
const determineStatus = useCallback((): ImportSessionState => {
|
||||
// Case 1: No session exists
|
||||
if (!session) {
|
||||
// If we received completion events, mark as completed
|
||||
if (completionEventReceived.current || queueDrainedEventReceived.current) {
|
||||
return {
|
||||
status: "completed",
|
||||
sessionId: null,
|
||||
progress: 100,
|
||||
stats: null,
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, system is idle
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Session exists - check its status field
|
||||
const { status, sessionId, stats } = session;
|
||||
|
||||
// Calculate progress
|
||||
const progress = stats.filesQueued > 0
|
||||
? Math.min(100, Math.round((stats.filesSucceeded / stats.filesQueued) * 100))
|
||||
: 0;
|
||||
|
||||
// Detect stuck sessions: status="running" but all files processed
|
||||
const isStuckOrComplete =
|
||||
status === "running" &&
|
||||
stats.filesQueued > 0 &&
|
||||
stats.filesProcessed >= stats.filesQueued;
|
||||
|
||||
// DEFINITIVE COMPLETION: Check status field or stuck session
|
||||
if (status === "completed" || isStuckOrComplete) {
|
||||
completionEventReceived.current = true;
|
||||
return {
|
||||
status: "completed",
|
||||
sessionId,
|
||||
progress: 100,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
completionEventReceived.current = true;
|
||||
return {
|
||||
status: "failed",
|
||||
sessionId,
|
||||
progress,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: true,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Check if session is actually running/active
|
||||
if (status === "running" || status === "active" || status === "processing") {
|
||||
// Check if there's actual progress happening
|
||||
const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0;
|
||||
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
||||
|
||||
// Only treat as active if there's progress OR it just started
|
||||
if (hasProgress && hasQueuedWork) {
|
||||
return {
|
||||
status: "running",
|
||||
sessionId,
|
||||
progress,
|
||||
stats: {
|
||||
filesQueued: stats.filesQueued,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesSucceeded: stats.filesSucceeded,
|
||||
filesFailed: stats.filesFailed,
|
||||
},
|
||||
isComplete: false,
|
||||
isActive: true,
|
||||
};
|
||||
} else {
|
||||
// Session says "running" but no progress - likely stuck/stale
|
||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: Session exists but has unknown/other status - treat as idle
|
||||
console.warn(`[useImportSessionStatus] Unknown session status: "${status}"`);
|
||||
return {
|
||||
status: "idle",
|
||||
sessionId: null,
|
||||
progress: 0,
|
||||
stats: null,
|
||||
isComplete: false,
|
||||
isActive: false,
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
/**
|
||||
* Initial fetch on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.log("[useImportSessionStatus] Initial mount - fetching session");
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
/**
|
||||
* Update state whenever session data changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
const newState = determineStatus();
|
||||
setSessionState(newState);
|
||||
|
||||
// Log status changes for debugging
|
||||
console.log("[useImportSessionStatus] Status determined:", {
|
||||
status: newState.status,
|
||||
sessionId: newState.sessionId,
|
||||
progress: newState.progress,
|
||||
isComplete: newState.isComplete,
|
||||
isActive: newState.isActive,
|
||||
stats: newState.stats,
|
||||
rawSession: session,
|
||||
});
|
||||
}, [determineStatus, session]);
|
||||
|
||||
/**
|
||||
* Listen to Socket.IO events for real-time completion detection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
|
||||
const handleSessionCompleted = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_COMPLETED event received");
|
||||
completionEventReceived.current = true;
|
||||
refetch(); // Immediately refetch to get final status
|
||||
};
|
||||
|
||||
const handleQueueDrained = () => {
|
||||
console.log("[useImportSessionStatus] LS_IMPORT_QUEUE_DRAINED event received");
|
||||
queueDrainedEventReceived.current = true;
|
||||
refetch(); // Immediately refetch to confirm no active session
|
||||
};
|
||||
|
||||
const handleSessionStarted = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_STARTED event received");
|
||||
// Reset completion flags when new session starts
|
||||
completionEventReceived.current = false;
|
||||
queueDrainedEventReceived.current = false;
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleSessionUpdated = () => {
|
||||
console.log("[useImportSessionStatus] IMPORT_SESSION_UPDATED event received");
|
||||
refetch();
|
||||
};
|
||||
|
||||
// Listen to completion events
|
||||
socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.on("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.on("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
|
||||
return () => {
|
||||
socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted);
|
||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||
socket.off("IMPORT_SESSION_STARTED", handleSessionStarted);
|
||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
||||
};
|
||||
}, [getSocket, refetch]);
|
||||
|
||||
return sessionState;
|
||||
};
|
||||
@@ -1,24 +1,30 @@
|
||||
import React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
import App from "./components/App";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import Settings from "./components/Settings/Settings";
|
||||
import { ErrorPage } from "./components/shared/ErrorPage";
|
||||
const rootEl = document.getElementById("root");
|
||||
const root = createRoot(rootEl);
|
||||
import i18n from "./shared/utils/i18n.util";
|
||||
import "./shared/utils/i18n.util";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Import from "./components/Import/Import";
|
||||
import Dashboard from "./components/Dashboard/Dashboard";
|
||||
import Search from "./components/Search/Search";
|
||||
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
|
||||
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
|
||||
import Volumes from "./components/Volumes/Volumes";
|
||||
import VolumeDetails from "./components/VolumeDetail/VolumeDetail";
|
||||
import WantedComics from "./components/WantedComics/WantedComics";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const Settings = lazy(() => import("./components/Settings/Settings"));
|
||||
const Import = lazy(() => import("./components/Import/Import"));
|
||||
const Dashboard = lazy(() => import("./components/Dashboard/Dashboard"));
|
||||
const Search = lazy(() => import("./components/Search/Search"));
|
||||
const TabulatedContentContainer = lazy(() => import("./components/Library/TabulatedContentContainer"));
|
||||
const ComicDetailContainer = lazy(() => import("./components/ComicDetail/ComicDetailContainer").then(m => ({ default: m.ComicDetailContainer })));
|
||||
const Volumes = lazy(() => import("./components/Volumes/Volumes"));
|
||||
const VolumeDetails = lazy(() => import("./components/VolumeDetail/VolumeDetail"));
|
||||
const WantedComics = lazy(() => import("./components/WantedComics/WantedComics"));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -48,6 +54,8 @@ const router = createBrowserRouter([
|
||||
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Suspense>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -47,7 +47,6 @@ const onSearchSent = async (item, instance, unsubscribe, searchInfo) => {
|
||||
|
||||
if (results.length > 0) {
|
||||
// We have results, download the best one
|
||||
console.log("SASAAAA", results);
|
||||
// const result = results[0];
|
||||
// SocketService.post(`search/${instance.id}/results/${result.id}/download`, {
|
||||
// priority: Utils.toApiPriority(item.priority),
|
||||
|
||||
@@ -42,6 +42,6 @@ const getIssueTypeDisplayName = (
|
||||
return { displayName, results };
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,161 +1,122 @@
|
||||
import { create } from "zustand";
|
||||
import { isNil } from "lodash";
|
||||
import io from "socket.io-client";
|
||||
import io, { Socket } from "socket.io-client";
|
||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||
import { produce } from "immer";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.min.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
/* Broadly, this file sets up:
|
||||
* 1. The zustand-based global client state
|
||||
* 2. socket.io client
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* Global application state interface
|
||||
*/
|
||||
export const useStore = create((set, get) => ({
|
||||
// Socket.io state
|
||||
socketIOInstance: {},
|
||||
// ComicVine Scraping status
|
||||
interface StoreState {
|
||||
/** Active socket.io connections by namespace */
|
||||
socketInstances: Record<string, Socket>;
|
||||
/**
|
||||
* Get or create socket connection for namespace
|
||||
* @param namespace - Socket namespace (default: "/")
|
||||
* @returns Socket instance
|
||||
*/
|
||||
getSocket: (namespace?: string) => Socket;
|
||||
/**
|
||||
* Disconnect and remove socket instance
|
||||
* @param namespace - Socket namespace to disconnect
|
||||
*/
|
||||
disconnectSocket: (namespace: string) => void;
|
||||
/** ComicVine scraping state */
|
||||
comicvine: {
|
||||
scrapingStatus: "",
|
||||
scrapingStatus: string;
|
||||
};
|
||||
/** Import job queue state and actions */
|
||||
importJobQueue: {
|
||||
successfulJobCount: number;
|
||||
failedJobCount: number;
|
||||
status: string | undefined;
|
||||
mostRecentImport: string | null;
|
||||
setStatus: (status: string) => void;
|
||||
setJobCount: (jobType: string, count: number) => void;
|
||||
setMostRecentImport: (fileName: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand store for global app state and socket management
|
||||
*/
|
||||
export const useStore = create<StoreState>((set, get) => ({
|
||||
socketInstances: {},
|
||||
|
||||
getSocket: (namespace = "/") => {
|
||||
const ns = namespace === "/" ? "" : namespace;
|
||||
const existing = get().socketInstances[namespace];
|
||||
// Return existing socket if it exists, regardless of connection state
|
||||
// This prevents creating duplicate sockets during connection phase
|
||||
if (existing) return existing;
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
const socket = io(`${SOCKET_BASE_URI}${ns}`, {
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
query: { sessionId },
|
||||
});
|
||||
|
||||
socket.on("sessionInitialized", (id) => localStorage.setItem("sessionId", id));
|
||||
if (sessionId) socket.emit("call", "socket.resumeSession", { sessionId, namespace });
|
||||
|
||||
socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", ({ completedJobCount, failedJobCount, queueStatus }) =>
|
||||
set((s) => ({ importJobQueue: { ...s.importJobQueue, successfulJobCount: completedJobCount, failedJobCount, status: queueStatus } }))
|
||||
);
|
||||
|
||||
socket.on("LS_COVER_EXTRACTED", ({ completedJobCount, importResult }) =>
|
||||
set((s) => ({ importJobQueue: { ...s.importJobQueue, successfulJobCount: completedJobCount, mostRecentImport: importResult.data.rawFileDetails.name } }))
|
||||
);
|
||||
|
||||
socket.on("LS_COVER_EXTRACTION_FAILED", ({ failedJobCount }) =>
|
||||
set((s) => ({ importJobQueue: { ...s.importJobQueue, failedJobCount } }))
|
||||
);
|
||||
|
||||
socket.on("LS_IMPORT_QUEUE_DRAINED", () => {
|
||||
set((s) => ({ importJobQueue: { ...s.importJobQueue, status: "drained" } }));
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||
localStorage.removeItem("sessionId");
|
||||
}, 500);
|
||||
});
|
||||
|
||||
socket.on("CV_SCRAPING_STATUS", ({ message }) =>
|
||||
set((s) => ({ comicvine: { ...s.comicvine, scrapingStatus: message } }))
|
||||
);
|
||||
|
||||
socket.on("searchResultsAvailable", ({ query }) =>
|
||||
toast(`Results found for query: ${JSON.stringify(query, null, 2)}`)
|
||||
);
|
||||
|
||||
set((s) => ({ socketInstances: { ...s.socketInstances, [namespace]: socket } }));
|
||||
return socket;
|
||||
},
|
||||
|
||||
// Import job queue and associated statuses
|
||||
disconnectSocket: (namespace) => {
|
||||
const socket = get().socketInstances[namespace];
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
set((s) => {
|
||||
const { [namespace]: _, ...rest } = s.socketInstances;
|
||||
return { socketInstances: rest };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
comicvine: { scrapingStatus: "" },
|
||||
|
||||
importJobQueue: {
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
status: undefined,
|
||||
setStatus: (status: string) =>
|
||||
set(
|
||||
produce((draftState) => {
|
||||
draftState.importJobQueue.status = status;
|
||||
}),
|
||||
),
|
||||
setJobCount: (jobType: string, count: Number) => {
|
||||
switch (jobType) {
|
||||
case "successful":
|
||||
set(
|
||||
produce((draftState) => {
|
||||
draftState.importJobQueue.successfulJobCount = count;
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case "failed":
|
||||
set(
|
||||
produce((draftState) => {
|
||||
draftState.importJobQueue.failedJobCount = count;
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
mostRecentImport: null,
|
||||
setMostRecentImport: (fileName: string) => {
|
||||
set(
|
||||
produce((state) => {
|
||||
state.importJobQueue.mostRecentImport = fileName;
|
||||
}),
|
||||
);
|
||||
},
|
||||
setStatus: (status) => set((s) => ({ importJobQueue: { ...s.importJobQueue, status } })),
|
||||
setJobCount: (jobType, count) => set((s) => ({
|
||||
importJobQueue: { ...s.importJobQueue, [jobType === "successful" ? "successfulJobCount" : "failedJobCount"]: count }
|
||||
})),
|
||||
setMostRecentImport: (fileName) => set((s) => ({ importJobQueue: { ...s.importJobQueue, mostRecentImport: fileName } })),
|
||||
},
|
||||
}));
|
||||
|
||||
const { getState, setState } = useStore;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/** Socket.IO initialization **/
|
||||
// 1. Fetch sessionId from localStorage
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
// 2. socket.io instantiation
|
||||
const socketIOInstance = io(SOCKET_BASE_URI, {
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
query: { sessionId },
|
||||
});
|
||||
// 3. Set the instance in global state
|
||||
setState({
|
||||
socketIOInstance,
|
||||
});
|
||||
|
||||
// Socket.io-based session restoration
|
||||
if (!isNil(sessionId)) {
|
||||
// 1. Resume the session
|
||||
socketIOInstance.emit(
|
||||
"call",
|
||||
"socket.resumeSession",
|
||||
{
|
||||
namespace: "/",
|
||||
sessionId,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
);
|
||||
} else {
|
||||
// 1. Inititalize the session and persist the sessionId to localStorage
|
||||
socketIOInstance.on("sessionInitialized", (sessionId) => {
|
||||
localStorage.setItem("sessionId", sessionId);
|
||||
});
|
||||
}
|
||||
// 2. If a job is in progress, restore the job counts and persist those to global state
|
||||
socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
||||
console.log("Active import in progress detected; restoring counts...");
|
||||
const { completedJobCount, failedJobCount, queueStatus } = data;
|
||||
setState((state) => ({
|
||||
importJobQueue: {
|
||||
...state.importJobQueue,
|
||||
successfulJobCount: completedJobCount,
|
||||
failedJobCount,
|
||||
status: queueStatus,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// 1a. Act on each comic issue successfully imported/failed, as indicated
|
||||
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
|
||||
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
|
||||
const { completedJobCount, importResult } = data;
|
||||
console.log(importResult);
|
||||
setState((state) => ({
|
||||
importJobQueue: {
|
||||
...state.importJobQueue,
|
||||
successfulJobCount: completedJobCount,
|
||||
mostRecentImport: importResult.data.rawFileDetails.name,
|
||||
},
|
||||
}));
|
||||
});
|
||||
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
|
||||
const { failedJobCount } = data;
|
||||
setState((state) => ({
|
||||
importJobQueue: {
|
||||
...state.importJobQueue,
|
||||
failedJobCount,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
socketIOInstance.on("searchResultsAvailable", (data) => {
|
||||
console.log(data);
|
||||
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
||||
});
|
||||
|
||||
// 1b. Clear the localStorage sessionId upon receiving the
|
||||
// LS_IMPORT_QUEUE_DRAINED event
|
||||
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
||||
localStorage.removeItem("sessionId");
|
||||
setState((state) => ({
|
||||
importJobQueue: {
|
||||
...state.importJobQueue,
|
||||
status: "drained",
|
||||
},
|
||||
}));
|
||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||
});
|
||||
|
||||
// ComicVine Scraping status
|
||||
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
|
||||
setState((state) => ({
|
||||
comicvine: {
|
||||
...state.comicvine,
|
||||
scrapingStatus: data.message,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
|
||||
import { MetadataPanel } from '../components/shared/MetadataPanel';
|
||||
import "../assets/scss/App.scss";
|
||||
import "../assets/scss/App.css";
|
||||
export default {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
|
||||
import { Card } from '../components/shared/Carda';
|
||||
import "../assets/scss/App.scss";
|
||||
import "../assets/scss/App.css";
|
||||
export default {
|
||||
/* 👇 The title prop is optional.
|
||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
||||
|
||||
3
src/client/types/index.d.ts
vendored
3
src/client/types/index.d.ts
vendored
@@ -6,3 +6,6 @@ declare module "*.png" {
|
||||
declare module "*.jpg";
|
||||
declare module "*.gif";
|
||||
declare module "*.less";
|
||||
|
||||
// Comic types are now generated from GraphQL schema
|
||||
// Import from '../../graphql/generated' instead
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Server side boilerplate with MongoDB as database, Express framework with Typescript
|
||||
|
||||
## Introduction
|
||||
|
||||
In the Server side of boilerplate, MongoDB and Express framework with Typescript has been utilized to reach a well structured and fully separated concerns source code.
|
||||
the source code has been separated into multiple layers and every layer fully explaind with lots of details in their own README files.
|
||||
|
||||
|
||||
### Nodemon
|
||||
|
||||
Nodemon is a utility monitors for any changes in the server-side source code, and automatically restarts the server. Nodemon is just for development purposes only.
|
||||
**nodemon.json** file is used to hold the configurations for Nodemon.
|
||||
|
||||
### Express
|
||||
|
||||
Express is a web application framework for Node.js. It is used to build our backend API's.
|
||||
|
||||
**src/server/index.ts** is the entry point to the server application which starts a server and listens on port 8085 for connections. The app responds with `{username: <username>}` for requests to the URL (/api/test). It is also configured to serve the static files from **dist** directory.
|
||||
@@ -1,41 +0,0 @@
|
||||
import express, { Router, Express } from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import router from "./route";
|
||||
|
||||
// call express
|
||||
const app: Express = express(); // define our app using express
|
||||
|
||||
const port: number = Number(process.env.PORT) || 8050; // set our port
|
||||
|
||||
// Configure app to respond with these headers for CORS purposes
|
||||
app.use(function (req, res, next) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Origin, X-Requested-With, Content-Type, Accept",
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// configure app to use bodyParser for
|
||||
// Getting data from body of requests
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
//
|
||||
// app.get("/", (req: Request, res: Response) => {
|
||||
// console.log("sending index.html");
|
||||
// res.sendFile(path.resolve(__dirname, "../dist/index.html"));
|
||||
// });
|
||||
|
||||
// REGISTER ROUTES
|
||||
// all of the routes will be prefixed with /api
|
||||
const routes: Router[] = Object.values(router);
|
||||
// app.use("/api", routes);
|
||||
|
||||
// Send index.html on root request
|
||||
// app.use(express.static("dist"));
|
||||
// app.use(express.static("public"));
|
||||
|
||||
// app.listen(port);
|
||||
|
||||
// console.log(`Server is listening on ${port}`);
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface IPathRoute {
|
||||
methods: string[];
|
||||
}
|
||||
|
||||
export interface Ipath {
|
||||
[route: string]: IPathRoute;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
## Read me for routes
|
||||
@@ -1,3 +0,0 @@
|
||||
import opds from "./routes/dummy.routes";
|
||||
|
||||
export default { opds };
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Ipath, IPathRoute } from "../interfaces/path.interface";
|
||||
|
||||
function path(url: string): IPathRoute {
|
||||
const allRoutes: Ipath = {
|
||||
"/extra": {
|
||||
methods: ["POST", "GET", "PUT"],
|
||||
},
|
||||
};
|
||||
return allRoutes[url];
|
||||
}
|
||||
|
||||
export default path;
|
||||
@@ -1,5 +0,0 @@
|
||||
import express, { Router } from "express";
|
||||
|
||||
const router: Router = express.Router();
|
||||
|
||||
export default router;
|
||||
@@ -1,3 +0,0 @@
|
||||
import router from "../router";
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,17 @@ module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
card: {
|
||||
wanted: "#f2d98d",
|
||||
delete: "#FFEBEE",
|
||||
scraped: "#b8edbc",
|
||||
uncompressed: "#FFF3E0",
|
||||
imported: "#d8dab0",
|
||||
},
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["PP Object Sans Regular", "sans-serif"],
|
||||
hasklig: ["Hasklig Regular", "monospace"],
|
||||
@@ -18,6 +29,13 @@ module.exports = {
|
||||
xl: "5rem",
|
||||
"2xl": "6rem",
|
||||
},
|
||||
screens: {
|
||||
sm: "640px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
xl: "1280px",
|
||||
"2xl": "1280px",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
@@ -26,6 +25,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"exclude": ["./src/server "],
|
||||
"include": ["./src/client/*", "./src/client/types/**/*.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Node 12",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"module": "commonjs",
|
||||
"target": "es2019",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "./server",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"exclude": ["./src/client"],
|
||||
"include": ["./src/server"]
|
||||
}
|
||||
132
vite-plugin-iconify.js
Normal file
132
vite-plugin-iconify.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export function iconifyPlugin() {
|
||||
const iconCache = new Map();
|
||||
const collections = new Map();
|
||||
|
||||
function loadCollection(prefix) {
|
||||
if (collections.has(prefix)) {
|
||||
return collections.get(prefix);
|
||||
}
|
||||
|
||||
try {
|
||||
const collectionPath = join(__dirname, 'node_modules', '@iconify-json', prefix, 'icons.json');
|
||||
const data = JSON.parse(readFileSync(collectionPath, 'utf8'));
|
||||
collections.set(prefix, data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getIconCSS(iconData, selector) {
|
||||
const { body, width, height } = iconData;
|
||||
const viewBox = `0 0 ${width || 24} ${height || 24}`;
|
||||
|
||||
// Create SVG data URI
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">${body}</svg>`;
|
||||
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
|
||||
return `${selector} {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: url("${dataUri}");
|
||||
mask-image: url("${dataUri}");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}`;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-iconify',
|
||||
|
||||
transform(code, id) {
|
||||
// Only process files that might contain icon classes
|
||||
if (!id.endsWith('.tsx') && !id.endsWith('.jsx') && !id.endsWith('.ts') && !id.endsWith('.js')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find all icon-[...] patterns
|
||||
const iconPattern = /icon-\[([^\]]+)\]/g;
|
||||
const matches = [...code.matchAll(iconPattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract unique icons
|
||||
const icons = new Set(matches.map(m => m[1]));
|
||||
|
||||
// Generate CSS for each icon
|
||||
for (const iconName of icons) {
|
||||
if (iconCache.has(iconName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse icon name (e.g., "solar--add-square-bold-duotone")
|
||||
const parts = iconName.split('--');
|
||||
if (parts.length !== 2) continue;
|
||||
|
||||
const [prefix, name] = parts;
|
||||
|
||||
// Load collection
|
||||
const collection = loadCollection(prefix);
|
||||
if (!collection || !collection.icons || !collection.icons[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get icon data
|
||||
const iconData = collection.icons[name];
|
||||
|
||||
// Generate CSS
|
||||
const selector = `.icon-\\[${iconName}\\]`;
|
||||
const iconCSS = getIconCSS(iconData, selector);
|
||||
|
||||
iconCache.set(iconName, iconCSS);
|
||||
} catch (e) {
|
||||
// Silently skip failed icons
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id === '/@iconify-css') {
|
||||
return id;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === '/@iconify-css') {
|
||||
const allCSS = Array.from(iconCache.values()).join('\n');
|
||||
return allCSS;
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml() {
|
||||
// Inject icon CSS into HTML
|
||||
const allCSS = Array.from(iconCache.values()).join('\n');
|
||||
if (allCSS) {
|
||||
return [
|
||||
{
|
||||
tag: 'style',
|
||||
attrs: { type: 'text/css' },
|
||||
children: allCSS,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,40 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: "public",
|
||||
base: "",
|
||||
build: "esnext",
|
||||
build: {
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
"react-vendor": ["react", "react-dom", "react-router", "react-router-dom"],
|
||||
"query-vendor": ["@tanstack/react-query", "@tanstack/react-table"],
|
||||
"ui-vendor": [
|
||||
"styled-components",
|
||||
"react-toastify",
|
||||
"react-select",
|
||||
"react-modal",
|
||||
"react-sliding-pane",
|
||||
"embla-carousel-react",
|
||||
"react-day-picker",
|
||||
"react-loader-spinner",
|
||||
],
|
||||
"utils-vendor": [
|
||||
"lodash",
|
||||
"date-fns",
|
||||
"dayjs",
|
||||
"axios",
|
||||
"rxjs",
|
||||
"socket.io-client",
|
||||
"i18next",
|
||||
"react-i18next",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
supported: {
|
||||
"top-level-await": true, //browsers can handle top-level-await features
|
||||
@@ -13,10 +42,6 @@ export default defineConfig({
|
||||
},
|
||||
server: { host: true },
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
// browser: true
|
||||
exportConditions: ["node"],
|
||||
}),
|
||||
react({
|
||||
// Use React plugin in all *.jsx and *.tsx files
|
||||
include: "**/*.{jsx,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user