Compare commits
6 Commits
dependabot
...
graphql-re
| Author | SHA1 | Date | |
|---|---|---|---|
| ea66419f33 | |||
| dc014a08ce | |||
| 90d6562f45 | |||
| 3563fef461 | |||
| 2968987c6b | |||
| 924ffae07e |
@@ -1 +0,0 @@
|
|||||||
module.exports = 'test-file-stub';
|
|
||||||
16
codegen.yml
16
codegen.yml
@@ -1,16 +0,0 @@
|
|||||||
schema: http://localhost:3000/graphql
|
|
||||||
documents: 'src/client/graphql/**/*.graphql'
|
|
||||||
generates:
|
|
||||||
src/client/graphql/generated.ts:
|
|
||||||
plugins:
|
|
||||||
- typescript
|
|
||||||
- typescript-operations
|
|
||||||
- typescript-react-query
|
|
||||||
config:
|
|
||||||
fetcher:
|
|
||||||
func: './fetcher#fetcher'
|
|
||||||
isReactHook: false
|
|
||||||
exposeFetcher: true
|
|
||||||
exposeQueryKeys: true
|
|
||||||
addInfiniteQuery: true
|
|
||||||
reactQueryVersion: 5
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
preset: 'ts-jest',
|
|
||||||
testEnvironment: 'jsdom',
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
|
||||||
moduleNameMapper: {
|
|
||||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
|
||||||
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
|
|
||||||
},
|
|
||||||
testMatch: [
|
|
||||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
|
||||||
'**/?(*.)+(spec|test).+(ts|tsx|js)',
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
|
||||||
tsconfig: {
|
|
||||||
jsx: 'react',
|
|
||||||
esModuleInterop: true,
|
|
||||||
allowSyntheticDefaultImports: true,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
||||||
collectCoverageFrom: [
|
|
||||||
'src/**/*.{ts,tsx}',
|
|
||||||
'!src/**/*.d.ts',
|
|
||||||
'!src/**/*.stories.tsx',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
require('@testing-library/jest-dom');
|
|
||||||
|
|
||||||
// Mock window.matchMedia
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
value: jest.fn().mockImplementation(query => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: jest.fn(),
|
|
||||||
removeListener: jest.fn(),
|
|
||||||
addEventListener: jest.fn(),
|
|
||||||
removeEventListener: jest.fn(),
|
|
||||||
dispatchEvent: jest.fn(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock localStorage
|
|
||||||
const localStorageMock = {
|
|
||||||
getItem: jest.fn(),
|
|
||||||
setItem: jest.fn(),
|
|
||||||
removeItem: jest.fn(),
|
|
||||||
clear: jest.fn(),
|
|
||||||
};
|
|
||||||
global.localStorage = localStorageMock;
|
|
||||||
13
nodemon.json
Normal file
13
nodemon.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"ignore": [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"node_modules",
|
||||||
|
"src/client"
|
||||||
|
],
|
||||||
|
"watch": [
|
||||||
|
"src/server"
|
||||||
|
],
|
||||||
|
"exec": "tsc -p tsconfig.server.json && node server/",
|
||||||
|
"ext": "ts"
|
||||||
|
}
|
||||||
21329
package-lock.json
generated
21329
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
227
package.json
227
package.json
@@ -2,144 +2,133 @@
|
|||||||
"name": "threetwo",
|
"name": "threetwo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "ThreeTwo! A good comic book curator.",
|
"description": "ThreeTwo! A good comic book curator.",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"typings": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "rimraf dist && yarn build && vite",
|
"dev": "rimraf dist && npm run build && vite",
|
||||||
"start": "yarn build && vite",
|
"start": "npm run build && vite",
|
||||||
"docs": "jsdoc -c jsdoc.json",
|
"docs": "jsdoc -c jsdoc.json",
|
||||||
"test": "jest",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:coverage": "jest --coverage",
|
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build"
|
||||||
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
|
|
||||||
"codegen:watch": "graphql-codegen --config codegen.yml --watch"
|
|
||||||
},
|
},
|
||||||
"author": "Rishi Ghan",
|
"author": "Rishi Ghan",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
"@floating-ui/react": "^0.27.18",
|
"@floating-ui/react": "^0.26.12",
|
||||||
"@floating-ui/react-dom": "^2.1.7",
|
"@floating-ui/react-dom": "^2.0.8",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-query": "^5.0.5",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@tanstack/react-table": "^8.9.3",
|
||||||
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"airdcpp-apisocket": "^3.0.0-beta.14",
|
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.8.2",
|
||||||
"axios-cache-interceptor": "^1.11.4",
|
"axios-cache-interceptor": "^1.0.1",
|
||||||
"axios-rate-limit": "^1.6.2",
|
"axios-rate-limit": "^1.3.0",
|
||||||
"babel-plugin-styled-components": "^2.1.4",
|
"babel-plugin-styled-components": "^2.1.4",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^2.28.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.10.6",
|
||||||
"ellipsize": "^0.7.0",
|
"ellipsize": "^0.5.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"express": "^4.20.0",
|
||||||
"filename-parser": "^1.0.4",
|
"filename-parser": "^1.0.2",
|
||||||
"final-form": "^5.0.0",
|
"final-form": "^4.20.2",
|
||||||
"final-form-arrays": "^4.0.0",
|
"final-form-arrays": "^3.0.2",
|
||||||
"focus-trap-react": "^12.0.0",
|
"focus-trap-react": "^10.2.3",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.0.0",
|
||||||
|
"graphql-request": "^7.2.0",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^8.1.0",
|
||||||
"i18next": "^25.8.13",
|
"i18next": "^23.11.1",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^2.5.0",
|
||||||
"immer": "^11.1.4",
|
"immer": "^10.0.3",
|
||||||
"jsdoc": "^4.0.5",
|
"jsdoc": "^3.6.10",
|
||||||
"lodash": "^4.17.23",
|
"keen-slider": "^6.8.6",
|
||||||
"pretty-bytes": "^7.1.0",
|
"lodash": "^4.17.21",
|
||||||
|
"pretty-bytes": "^5.6.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"qs": "^6.15.0",
|
"qs": "^6.10.5",
|
||||||
"react": "^19.2.4",
|
"react": "^18.3.1",
|
||||||
"react-collapsible": "^2.10.0",
|
"react-collapsible": "^2.9.0",
|
||||||
"react-comic-viewer": "^0.5.1",
|
"react-comic-viewer": "^0.4.0",
|
||||||
"react-day-picker": "^9.13.2",
|
"react-day-picker": "^8.10.0",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^18.2.0",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.0",
|
||||||
"react-final-form": "^7.0.0",
|
"react-final-form": "^6.5.9",
|
||||||
"react-final-form-arrays": "^4.0.0",
|
"react-final-form-arrays": "^3.1.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^14.1.0",
|
||||||
"react-loader-spinner": "^8.0.2",
|
"react-loader-spinner": "^4.0.0",
|
||||||
"react-modal": "^3.16.3",
|
"react-modal": "^3.15.1",
|
||||||
"react-router": "^7.13.1",
|
"react-router": "^7.5.2",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^6.9.0",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.8.0",
|
||||||
"react-select-async-paginate": "^0.7.11",
|
"react-select-async-paginate": "^0.7.2",
|
||||||
"react-sliding-pane": "^7.3.0",
|
"react-sliding-pane": "^7.1.0",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.3.4",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^10.0.5",
|
||||||
"rxjs": "^7.8.2",
|
"socket.io-client": "^4.3.2",
|
||||||
"socket.io-client": "^4.8.3",
|
"styled-components": "^6.1.0",
|
||||||
"styled-components": "^6.3.11",
|
|
||||||
"threetwo-ui-typings": "^1.0.14",
|
"threetwo-ui-typings": "^1.0.14",
|
||||||
"vite": "^7.3.1",
|
"vite": "^5.4.19",
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.0",
|
||||||
"websocket": "^1.0.35",
|
"websocket": "^1.0.34",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^4.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^6.1.2",
|
"@iconify-json/solar": "^1.1.8",
|
||||||
"@graphql-codegen/typescript": "^5.0.8",
|
"@iconify/tailwind": "^0.1.4",
|
||||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
"@storybook/addon-essentials": "^7.4.1",
|
||||||
"@graphql-codegen/typescript-react-query": "^6.1.2",
|
"@storybook/addon-interactions": "^7.4.1",
|
||||||
"@iconify-json/solar": "^1.2.5",
|
"@storybook/addon-links": "^7.4.1",
|
||||||
"@iconify/json": "^2.2.443",
|
"@storybook/addon-onboarding": "^1.0.8",
|
||||||
"@iconify/tailwind": "^1.2.0",
|
"@storybook/blocks": "^7.4.1",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@storybook/react": "^7.4.1",
|
||||||
"@iconify/utils": "^3.1.0",
|
"@storybook/react-vite": "^7.4.1",
|
||||||
"@storybook/addon-essentials": "^8.6.17",
|
"@storybook/testing-library": "^0.2.0",
|
||||||
"@storybook/addon-interactions": "^8.6.17",
|
"@tanstack/eslint-plugin-query": "^5.0.5",
|
||||||
"@storybook/addon-links": "^8.6.17",
|
"@tanstack/react-query-devtools": "^5.1.0",
|
||||||
"@storybook/addon-onboarding": "^8.6.17",
|
"@tsconfig/node14": "^1.0.0",
|
||||||
"@storybook/blocks": "^8.6.17",
|
"@types/ellipsize": "^0.1.1",
|
||||||
"@storybook/react": "^8.6.17",
|
"@types/express": "^4.17.8",
|
||||||
"@storybook/react-vite": "^8.6.17",
|
"@types/jest": "^26.0.20",
|
||||||
"@storybook/testing-library": "^0.2.2",
|
"@types/lodash": "^4.14.168",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@types/node": "^14.14.34",
|
||||||
"@tanstack/eslint-plugin-query": "^5.91.4",
|
"@types/react": "^18.0.28",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@types/react-redux": "^7.1.25",
|
||||||
"@testing-library/react": "^16.3.2",
|
"autoprefixer": "^10.4.16",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"body-parser": "^1.19.0",
|
||||||
"@types/ellipsize": "^0.1.3",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/lodash": "^4.17.24",
|
|
||||||
"@types/node": "^25.3.0",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/react-redux": "^7.1.34",
|
|
||||||
"autoprefixer": "^10.4.27",
|
|
||||||
"docdash": "^2.0.2",
|
"docdash": "^2.0.2",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^8.49.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^8.1.0",
|
||||||
"eslint-plugin-css-modules": "^2.12.0",
|
"eslint-plugin-css-modules": "^2.11.0",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"eslint-plugin-jsdoc": "^62.7.1",
|
"eslint-plugin-jsdoc": "^46.6.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
"eslint-plugin-storybook": "^0.11.1",
|
"eslint-plugin-storybook": "^0.6.13",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"express": "^4.20.0",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^29.6.3",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"nodemon": "^3.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.4.32",
|
||||||
"postcss-import": "^16.1.1",
|
"postcss-import": "^15.1.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^2.2.1",
|
||||||
"react-refresh": "^0.18.0",
|
"react-refresh": "^0.14.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^4.1.3",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.77.0",
|
||||||
"storybook": "^8.6.17",
|
"storybook": "^7.3.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"ts-jest": "^29.4.6",
|
|
||||||
"tui-jsdoc-template": "^1.2.2",
|
"tui-jsdoc-template": "^1.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.1.6"
|
||||||
"wait-on": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"jackspeak": "2.1.1"
|
"jackspeak": "2.1.1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"postcss-import": {},
|
"postcss-import": {},
|
||||||
"@tailwindcss/postcss": {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const toggleAirDCPPSocketConnectionStatus =
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
console.log("Can't set AirDC++ socket status.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling could be added here if needed
|
console.log(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,9 +73,10 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
console.log("Could not complete request.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_GENERIC_FAILURE,
|
type: CV_API_GENERIC_FAILURE,
|
||||||
error,
|
error,
|
||||||
@@ -98,6 +99,7 @@ export const getIssuesForSeries =
|
|||||||
comicObjectID,
|
comicObjectID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log(issues);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||||
issues: issues.data.results,
|
issues: issues.data.results,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
LS_SET_QUEUE_STATUS,
|
LS_SET_QUEUE_STATUS,
|
||||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
|
import { success } from "react-notification-system-redux";
|
||||||
|
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ export const getComicBooks = (options) => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
console.log("Unrecognized comic status.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,11 +219,12 @@ export const fetchVolumeGroups = () => async (dispatch) => {
|
|||||||
data: response.data,
|
data: response.data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling could be added here if needed
|
console.log(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const fetchComicVineMatches =
|
export const fetchComicVineMatches =
|
||||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||||
|
console.log(issueSearchQuery);
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -271,7 +273,7 @@ export const fetchComicVineMatches =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling could be added here if needed
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export const fetchMetronResource = async (options) => {
|
|||||||
`${METRON_SERVICE_URI}/fetchResource`,
|
`${METRON_SERVICE_URI}/fetchResource`,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
console.log(metronResourceResults);
|
||||||
|
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||||
const results = metronResourceResults.data.results.map((result) => {
|
const results = metronResourceResults.data.results.map((result) => {
|
||||||
return {
|
return {
|
||||||
label: result.name || result.__str__,
|
label: result.name || result.__str__,
|
||||||
|
|||||||
15
src/client/assets/scss/App.scss
Normal file
15
src/client/assets/scss/App.scss
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import React, { ReactElement, useEffect } from "react";
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Navbar2 } from "./shared/Navbar2";
|
import { Navbar2 } from "./shared/Navbar2";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import "../assets/scss/App.css";
|
import "../assets/scss/App.scss";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
|
||||||
export const App = (): ReactElement => {
|
export const App = (): ReactElement => {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings";
|
||||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import { difference } from "../../shared/utils/object.utils";
|
import { difference } from "../../shared/utils/object.utils";
|
||||||
@@ -15,44 +14,104 @@ import { useStore } from "../../store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
|
||||||
interface IAcquisitionPanelProps {
|
interface IAcquisitionPanelProps {
|
||||||
query: any;
|
query: any;
|
||||||
comicObjectId: any;
|
comicObjectId: string;
|
||||||
comicObject: any;
|
comicObject: any;
|
||||||
settings: any;
|
settings: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AirDCPPConfig {
|
||||||
|
protocol: string;
|
||||||
|
hostname: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: {
|
||||||
|
id: string;
|
||||||
|
str: string;
|
||||||
|
};
|
||||||
|
size: number;
|
||||||
|
slots: {
|
||||||
|
total: number;
|
||||||
|
free: number;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
user: {
|
||||||
|
nicks: string;
|
||||||
|
flags: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dupe?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInstanceData {
|
||||||
|
id: number;
|
||||||
|
owner: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInfo {
|
||||||
|
query: {
|
||||||
|
pattern: string;
|
||||||
|
extensions: string[];
|
||||||
|
file_type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hub {
|
||||||
|
hub_url: string;
|
||||||
|
identity: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFormValues {
|
||||||
|
issueName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const AcquisitionPanel = (
|
export const AcquisitionPanel = (
|
||||||
props: IAcquisitionPanelProps,
|
props: IAcquisitionPanelProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
const socketRef = useRef<Socket>();
|
const socketRef = useRef<Socket>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const [dcppQuery, setDcppQuery] = useState({});
|
const [dcppQuery, setDcppQuery] = useState<SearchQuery | null>(null);
|
||||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<SearchResult[]>([]);
|
||||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<SearchInstanceData | null>(null);
|
||||||
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<SearchInfo | null>(null);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { comicObjectId } = props;
|
const { comicObjectId } = props;
|
||||||
const issueName = props.query.issue.name || "";
|
const issueName = props.query.issue.name || "";
|
||||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||||
|
|
||||||
|
// Search timeout duration in milliseconds (30 seconds)
|
||||||
|
const SEARCH_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = useStore.getState().getSocket("manual");
|
const socket = useStore.getState().getSocket("manual");
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const handleResultAdded = ({ result }: any) => {
|
const handleResultAdded = ({ result }: { result: SearchResult }) => {
|
||||||
setAirDCPPSearchResults((prev) =>
|
setAirDCPPSearchResults((prev) =>
|
||||||
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResultUpdated = ({ result }: any) => {
|
const handleResultUpdated = ({ result }: { result: SearchResult }) => {
|
||||||
setAirDCPPSearchResults((prev) => {
|
setAirDCPPSearchResults((prev) => {
|
||||||
const idx = prev.findIndex((r) => r.id === result.id);
|
const idx = prev.findIndex((r) => r.id === result.id);
|
||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
@@ -63,45 +122,86 @@ export const AcquisitionPanel = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchInitiated = (data: any) => {
|
const handleSearchInitiated = (data: { instance: SearchInstanceData }) => {
|
||||||
setAirDCPPSearchInstance(data.instance);
|
setAirDCPPSearchInstance(data.instance);
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a timeout to stop searching after SEARCH_TIMEOUT_MS
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsSearching(false);
|
||||||
|
console.log(`Search timeout reached after ${SEARCH_TIMEOUT_MS / 1000} seconds`);
|
||||||
|
}, SEARCH_TIMEOUT_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchesSent = (data: any) => {
|
const handleSearchesSent = (data: { searchInfo: SearchInfo }) => {
|
||||||
setAirDCPPSearchInfo(data.searchInfo);
|
setAirDCPPSearchInfo(data.searchInfo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearchError = (error: { message: string }) => {
|
||||||
|
setSearchError(error.message || "Search failed");
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
// Clear timeout on error
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
searchTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchCompleted = () => {
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
// Clear timeout when search completes
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
searchTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Subscribe once ---
|
// --- Subscribe once ---
|
||||||
socket.on("searchResultAdded", handleResultAdded);
|
socket.on("searchResultAdded", handleResultAdded);
|
||||||
socket.on("searchResultUpdated", handleResultUpdated);
|
socket.on("searchResultUpdated", handleResultUpdated);
|
||||||
socket.on("searchInitiated", handleSearchInitiated);
|
socket.on("searchInitiated", handleSearchInitiated);
|
||||||
socket.on("searchesSent", handleSearchesSent);
|
socket.on("searchesSent", handleSearchesSent);
|
||||||
|
socket.on("searchError", handleSearchError);
|
||||||
|
socket.on("searchCompleted", handleSearchCompleted);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("searchResultAdded", handleResultAdded);
|
socket.off("searchResultAdded", handleResultAdded);
|
||||||
socket.off("searchResultUpdated", handleResultUpdated);
|
socket.off("searchResultUpdated", handleResultUpdated);
|
||||||
socket.off("searchInitiated", handleSearchInitiated);
|
socket.off("searchInitiated", handleSearchInitiated);
|
||||||
socket.off("searchesSent", handleSearchesSent);
|
socket.off("searchesSent", handleSearchesSent);
|
||||||
// if you want to fully close the socket:
|
socket.off("searchError", handleSearchError);
|
||||||
// useStore.getState().disconnectSocket("/manual");
|
socket.off("searchCompleted", handleSearchCompleted);
|
||||||
|
|
||||||
|
// Clean up timeout on unmount
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [SEARCH_TIMEOUT_MS]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: settings,
|
data: settings,
|
||||||
isLoading,
|
isLoading: isLoadingSettings,
|
||||||
isError,
|
isError: isSettingsError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["settings"],
|
queryKey: ["settings"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
await axios({
|
await axios({
|
||||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: hubs } = useQuery({
|
const { data: hubs, isLoading: isLoadingHubs } = useQuery({
|
||||||
queryKey: ["hubs"],
|
queryKey: ["hubs", settings?.data.directConnect?.client?.host],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
await axios({
|
await axios({
|
||||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||||
@@ -110,45 +210,72 @@ export const AcquisitionPanel = (
|
|||||||
host: settings?.data.directConnect?.client?.host,
|
host: settings?.data.directConnect?.client?.host,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
enabled: !!settings?.data?.directConnect?.client?.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get AirDC++ config from settings
|
||||||
|
const airDCPPConfig: AirDCPPConfig | null = settings?.data?.directConnect?.client
|
||||||
|
? {
|
||||||
|
protocol: settings.data.directConnect.client.protocol || "ws",
|
||||||
|
hostname: typeof settings.data.directConnect.client.host === 'string'
|
||||||
|
? settings.data.directConnect.client.host
|
||||||
|
: `${settings.data.directConnect.client.host?.hostname || 'localhost'}:${settings.data.directConnect.client.host?.port || '5600'}`,
|
||||||
|
username: settings.data.directConnect.client.username || "admin",
|
||||||
|
password: settings.data.directConnect.client.password || "password",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dcppSearchQuery = {
|
if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) {
|
||||||
query: {
|
const dcppSearchQuery = {
|
||||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
query: {
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||||
},
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
hub_urls: map(hubs?.data, (item) => item.value),
|
},
|
||||||
priority: 5,
|
hub_urls: map(hubs.data, (item) => item.value),
|
||||||
};
|
priority: 5,
|
||||||
setDcppQuery(dcppSearchQuery);
|
};
|
||||||
|
setDcppQuery(dcppSearchQuery as any);
|
||||||
|
}
|
||||||
}, [hubs, sanitizedIssueName]);
|
}, [hubs, sanitizedIssueName]);
|
||||||
|
|
||||||
const search = async (searchData: any) => {
|
const search = async (searchData: any) => {
|
||||||
|
if (!airDCPPConfig) {
|
||||||
|
setSearchError("AirDC++ configuration not found in settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socketRef.current) {
|
||||||
|
setSearchError("Socket connection not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAirDCPPSearchResults([]);
|
setAirDCPPSearchResults([]);
|
||||||
socketRef.current?.emit("call", "socket.search", {
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
|
||||||
|
socketRef.current.emit("call", "socket.search", {
|
||||||
query: searchData,
|
query: searchData,
|
||||||
namespace: "/manual",
|
namespace: "/manual",
|
||||||
config: {
|
config: airDCPPConfig,
|
||||||
protocol: `ws`,
|
|
||||||
hostname: `192.168.1.119:5600`,
|
|
||||||
username: `admin`,
|
|
||||||
password: `password`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const download = async (
|
const download = async (
|
||||||
searchInstanceId: Number,
|
searchInstanceId: number,
|
||||||
resultId: String,
|
resultId: string,
|
||||||
comicObjectId: String,
|
comicObjectId: string,
|
||||||
name: String,
|
name: string,
|
||||||
size: Number,
|
size: number,
|
||||||
type: any,
|
type: SearchResult["type"],
|
||||||
config: any,
|
config: AirDCPPConfig,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
socketRef.current?.emit(
|
if (!socketRef.current) {
|
||||||
|
console.error("Socket connection not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socketRef.current.emit(
|
||||||
"call",
|
"call",
|
||||||
"socket.download",
|
"socket.download",
|
||||||
{
|
{
|
||||||
@@ -160,19 +287,27 @@ export const AcquisitionPanel = (
|
|||||||
type,
|
type,
|
||||||
config,
|
config,
|
||||||
},
|
},
|
||||||
(data: any) => {
|
(data: any) => console.log("Download initiated:", data),
|
||||||
// Download initiated
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDCPPSearchResults = async (searchQuery) => {
|
const getDCPPSearchResults = async (searchQuery: SearchFormValues) => {
|
||||||
|
if (!searchQuery.issueName || searchQuery.issueName.trim() === "") {
|
||||||
|
setSearchError("Please enter a search term");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hubs?.data || !Array.isArray(hubs.data) || hubs.data.length === 0) {
|
||||||
|
setSearchError("No hubs configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const manualQuery = {
|
const manualQuery = {
|
||||||
query: {
|
query: {
|
||||||
pattern: `${searchQuery.issueName}`,
|
pattern: `${searchQuery.issueName.trim()}`,
|
||||||
extensions: ["cbz", "cbr", "cb7"],
|
extensions: ["cbz", "cbr", "cb7"],
|
||||||
},
|
},
|
||||||
hub_urls: [hubs?.data[0].hub_url],
|
hub_urls: [hubs.data[0].hub_url],
|
||||||
priority: 5,
|
priority: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +317,12 @@ export const AcquisitionPanel = (
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-5 mb-3">
|
<div className="mt-5 mb-3">
|
||||||
{!isEmpty(hubs?.data) ? (
|
{isLoadingSettings || isLoadingHubs ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin" />
|
||||||
|
Loading configuration...
|
||||||
|
</div>
|
||||||
|
) : !isEmpty(hubs?.data) ? (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={getDCPPSearchResults}
|
onSubmit={getDCPPSearchResults}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@@ -202,20 +342,31 @@ export const AcquisitionPanel = (
|
|||||||
{...input}
|
{...input}
|
||||||
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||||
placeholder="Type an issue/volume name"
|
placeholder="Type an issue/volume name"
|
||||||
|
disabled={isSearching}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={isSearching}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row items-center">
|
||||||
Search DC++
|
{isSearching ? (
|
||||||
<div className="h-5 w-5 ml-2">
|
<>
|
||||||
<img
|
<i className="icon-[solar--refresh-bold-duotone] h-5 w-5 animate-spin mr-2" />
|
||||||
src="/src/client/assets/img/airdcpp_logo.svg"
|
Searching...
|
||||||
className="h-5 w-5"
|
</>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
Search DC++
|
||||||
|
<div className="h-5 w-5 ml-2">
|
||||||
|
<img
|
||||||
|
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,26 +387,36 @@ export const AcquisitionPanel = (
|
|||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Error Display */}
|
||||||
|
{searchError && (
|
||||||
|
<article
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-red-500 bg-red-50 p-4 dark:border-s-4 dark:border-red-600 dark:bg-red-300 dark:text-slate-600"
|
||||||
|
>
|
||||||
|
<strong>Error:</strong> {searchError}
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
{/* configured hub */}
|
{/* configured hub */}
|
||||||
{!isEmpty(hubs?.data) && (
|
{!isEmpty(hubs?.data) && hubs?.data[0] && (
|
||||||
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||||
<span className="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
{hubs && hubs?.data[0].hub_url}
|
{hubs.data[0].hub_url}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AirDC++ search instance details */}
|
{/* AirDC++ search instance details */}
|
||||||
{!isNil(airDCPPSearchInstance) &&
|
{airDCPPSearchInstance &&
|
||||||
!isEmpty(airDCPPSearchInfo) &&
|
airDCPPSearchInfo &&
|
||||||
!isNil(hubs) && (
|
hubs?.data && (
|
||||||
<div className="flex flex-row gap-3 my-5 font-hasklig">
|
<div className="flex flex-row gap-3 my-5 font-hasklig">
|
||||||
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
<dl>
|
<dl>
|
||||||
<dt>
|
<dt>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
{hubs?.data.map((value, idx: string) => (
|
{hubs.data.map((value: Hub, idx: number) => (
|
||||||
<span className="tag is-warning" key={idx}>
|
<span className="tag is-warning" key={idx}>
|
||||||
{value.identity.name}
|
{value.identity.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -295,7 +456,7 @@ export const AcquisitionPanel = (
|
|||||||
|
|
||||||
{/* AirDC++ results */}
|
{/* AirDC++ results */}
|
||||||
<div className="">
|
<div className="">
|
||||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
{airDCPPSearchResults.length > 0 ? (
|
||||||
<div className="overflow-x-auto max-w-full mt-6">
|
<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">
|
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -347,9 +508,9 @@ export const AcquisitionPanel = (
|
|||||||
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||||
{users.user.nicks}
|
{users.user.nicks}
|
||||||
</span>
|
</span>
|
||||||
{users.user.flags.map((flag, idx) => (
|
{users.user.flags.map((flag: string, flagIdx: number) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={flagIdx}
|
||||||
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"
|
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>
|
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||||
@@ -380,23 +541,21 @@ export const AcquisitionPanel = (
|
|||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<td className="px-2 py-3">
|
<td className="px-2 py-3">
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
className="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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
download(
|
if (airDCPPSearchInstance && airDCPPConfig) {
|
||||||
airDCPPSearchInstance.id,
|
download(
|
||||||
id,
|
airDCPPSearchInstance.id,
|
||||||
comicObjectId,
|
id,
|
||||||
name,
|
comicObjectId,
|
||||||
size,
|
name,
|
||||||
type,
|
size,
|
||||||
{
|
type,
|
||||||
protocol: `ws`,
|
airDCPPConfig,
|
||||||
hostname: `192.168.1.119:5600`,
|
);
|
||||||
username: `admin`,
|
}
|
||||||
password: `password`,
|
}}
|
||||||
},
|
disabled={!airDCPPSearchInstance || !airDCPPConfig}
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||||
@@ -408,7 +567,7 @@ export const AcquisitionPanel = (
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !isSearching ? (
|
||||||
<div className="">
|
<div className="">
|
||||||
<article
|
<article
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -434,6 +593,11 @@ export const AcquisitionPanel = (
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 mt-6 p-4">
|
||||||
|
<i className="icon-[solar--refresh-bold-duotone] h-6 w-6 animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
|
|
||||||
export const Menu = (props): ReactElement => {
|
export const Menu = (props: any): ReactElement => {
|
||||||
const {
|
const {
|
||||||
filteredActionOptions,
|
filteredActionOptions,
|
||||||
customStyles,
|
customStyles,
|
||||||
@@ -13,11 +13,11 @@ export const Menu = (props): ReactElement => {
|
|||||||
<Select
|
<Select
|
||||||
components={{ Placeholder }}
|
components={{ Placeholder }}
|
||||||
placeholder={
|
placeholder={
|
||||||
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
<span className="inline-flex flex-row items-center gap-1.5 pt-1">
|
||||||
<div className="w-6 h-6">
|
<div className="w-4 h-4">
|
||||||
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
|
<i className="icon-[solar--cursor-bold-duotone] w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>Select An Action</div>
|
<div className="text-sm">Select An Action</div>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
styles={customStyles}
|
styles={customStyles}
|
||||||
|
|||||||
@@ -1,75 +1,76 @@
|
|||||||
import React, { useState, ReactElement, useCallback } from "react";
|
import React, { useState, ReactElement, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
|
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||||
|
|
||||||
import { RawFileDetails } from "./RawFileDetails";
|
import { RawFileDetails } from "./RawFileDetails";
|
||||||
|
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||||
|
|
||||||
import TabControls from "./TabControls";
|
import TabControls from "./TabControls";
|
||||||
|
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||||
import { Menu } from "./ActionMenu/Menu";
|
import { Menu } from "./ActionMenu/Menu";
|
||||||
|
import { 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 { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||||
import { components } from "react-select";
|
import { components } from "react-select";
|
||||||
|
|
||||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||||
|
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||||
|
import Loader from "react-loader-spinner";
|
||||||
import SlidingPane from "react-sliding-pane";
|
import SlidingPane from "react-sliding-pane";
|
||||||
|
import Modal from "react-modal";
|
||||||
|
import ComicViewer from "react-comic-viewer";
|
||||||
|
|
||||||
|
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
|
import axios from "axios";
|
||||||
import { styled } from "styled-components";
|
import { styled } from "styled-components";
|
||||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||||
|
import { refineQuery } from "filename-parser";
|
||||||
|
|
||||||
// Extracted modules
|
interface ComicDetailProps {
|
||||||
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: {
|
data: {
|
||||||
_id: string;
|
_id: string;
|
||||||
rawFileDetails?: RawFileDetailsType;
|
rawFileDetails?: any;
|
||||||
inferredMetadata: {
|
inferredMetadata?: {
|
||||||
issue?: InferredIssue;
|
issue?: {
|
||||||
|
year?: string;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicvine?: ComicVineMetadata;
|
comicvine?: any;
|
||||||
locg?: any;
|
locg?: any;
|
||||||
comicInfo?: any;
|
comicInfo?: any;
|
||||||
};
|
};
|
||||||
acquisition?: Acquisition;
|
acquisition?: {
|
||||||
|
directconnect?: {
|
||||||
|
downloads?: any[];
|
||||||
|
};
|
||||||
|
torrent?: any[];
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
userSettings?: any;
|
userSettings?: any;
|
||||||
queryClient?: any;
|
}
|
||||||
comicObjectId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
* Component for displaying the metadata for a comic in greater detail.
|
||||||
* for metadata, archive operations, and acquisition.
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* return (
|
||||||
|
* <ComicDetail/>
|
||||||
|
* )
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
@@ -79,22 +80,135 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||||
acquisition,
|
acquisition,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
},
|
},
|
||||||
userSettings,
|
userSettings,
|
||||||
queryClient,
|
|
||||||
comicObjectId: comicObjectIdProp,
|
|
||||||
} = data;
|
} = data;
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
|
const [modalIsOpen, setIsOpen] = useState(false);
|
||||||
|
const [comicVineMatches, setComicVineMatches] = useState([]);
|
||||||
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
|
||||||
|
// const dispatch = useDispatch();
|
||||||
|
|
||||||
|
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: any) => {
|
||||||
|
// references are now sync'd and can be accessed.
|
||||||
|
// subtitle.style.color = "#f00";
|
||||||
|
console.log("kolaveri", things);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// sliding panel init
|
||||||
|
const contentForSlidingPanel: Record<string, { content: (props?: any) => JSX.Element }> = {
|
||||||
|
CVMatches: {
|
||||||
|
content: (props?: any) => (
|
||||||
|
<>
|
||||||
|
<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: any,
|
||||||
|
issueSearchQuery: any,
|
||||||
|
seriesSearchQuery: any,
|
||||||
|
) => {
|
||||||
|
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: string) => {
|
||||||
|
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: any) => match);
|
||||||
|
}
|
||||||
|
const scoredMatches = matches.sort((a: any, b: any) => b.score - a.score);
|
||||||
|
setComicVineMatches(scoredMatches);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Action event handlers
|
// Action event handlers
|
||||||
const openDrawerWithCVMatches = () => {
|
const openDrawerWithCVMatches = () => {
|
||||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
let seriesSearchQuery: any = {};
|
||||||
|
let issueSearchQuery: any = {};
|
||||||
|
|
||||||
|
if (!isUndefined(rawFileDetails)) {
|
||||||
|
issueSearchQuery = refineQuery((rawFileDetails as any).name);
|
||||||
|
} else if (!isEmpty(comicvine)) {
|
||||||
|
issueSearchQuery = refineQuery((comicvine as any).name);
|
||||||
|
}
|
||||||
|
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
||||||
setSlidingPanelContentId("CVMatches");
|
setSlidingPanelContentId("CVMatches");
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
};
|
};
|
||||||
@@ -104,16 +218,47 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
setVisible(true);
|
setVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Action menu handler
|
// Actions menu options and handler
|
||||||
const Placeholder = components.Placeholder;
|
const CVMatchLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--magic-stick-3-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Match on ComicVine</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const editLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--pen-2-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Edit Metadata</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const deleteLabel = (
|
||||||
|
<span className="inline-flex flex-row items-center gap-1.5">
|
||||||
|
<div className="w-4 h-4">
|
||||||
|
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Delete Comic</div>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const Placeholder = (props: any) => {
|
||||||
|
return <components.Placeholder {...props} />;
|
||||||
|
};
|
||||||
|
const actionOptions = [
|
||||||
|
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||||
|
{ value: "edit-metdata", label: editLabel },
|
||||||
|
{ value: "delete-comic", label: deleteLabel },
|
||||||
|
];
|
||||||
|
|
||||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||||
if (isUndefined(rawFileDetails)) {
|
if (isUndefined(rawFileDetails)) {
|
||||||
return item.value !== "match-on-comic-vine";
|
return item.value !== "match-on-comic-vine";
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
const handleActionSelection = (action: any) => {
|
||||||
const handleActionSelection = (action: ActionOption) => {
|
|
||||||
switch (action.value) {
|
switch (action.value) {
|
||||||
case "match-on-comic-vine":
|
case "match-on-comic-vine":
|
||||||
openDrawerWithCVMatches();
|
openDrawerWithCVMatches();
|
||||||
@@ -122,14 +267,40 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
openEditMetadataPanel();
|
openEditMetadataPanel();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
console.log("No valid action selected.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const customStyles = {
|
||||||
|
menu: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
backgroundColor: "rgb(156, 163, 175)",
|
||||||
|
}),
|
||||||
|
placeholder: (base: any) => ({
|
||||||
|
...base,
|
||||||
|
color: "black",
|
||||||
|
}),
|
||||||
|
option: (base: any, { data, isDisabled, isFocused, isSelected }: 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)",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// Check for metadata availability
|
// check for the availability of CV metadata
|
||||||
const isComicBookMetadataAvailable =
|
const isComicBookMetadataAvailable =
|
||||||
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
!isUndefined(comicvine) && !isUndefined((comicvine as any)?.volumeInformation);
|
||||||
|
|
||||||
|
// check for the availability of rawFileDetails
|
||||||
const areRawFileDetailsAvailable =
|
const areRawFileDetailsAvailable =
|
||||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||||
|
|
||||||
@@ -139,54 +310,110 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
locg,
|
locg,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query for airdc++
|
// query for airdc++
|
||||||
const airDCPPQuery = {
|
const airDCPPQuery = {
|
||||||
issue: {
|
issue: {
|
||||||
name: issueName,
|
name: issueName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create tab configuration
|
// Tab content and header details
|
||||||
const tabGroup = createTabConfig({
|
const tabGroup = [
|
||||||
data: data.data,
|
{
|
||||||
comicInfo,
|
id: 1,
|
||||||
isComicBookMetadataAvailable,
|
name: "Volume Information",
|
||||||
areRawFileDetailsAvailable,
|
icon: (
|
||||||
airDCPPQuery,
|
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
||||||
comicObjectId: _id,
|
),
|
||||||
userSettings,
|
content: isComicBookMetadataAvailable ? (
|
||||||
issueName,
|
<VolumeInformation data={data.data} key={1} />
|
||||||
acquisition,
|
) : 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}
|
||||||
|
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={_id} issueName={issueName} />,
|
||||||
|
shouldShow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Downloads",
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
{(acquisition?.directconnect?.downloads?.length || 0) +
|
||||||
|
(acquisition?.torrent?.length || 0)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
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
|
||||||
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||||
|
|
||||||
// Sliding panel content mapping
|
// Determine which cover image to use:
|
||||||
const renderSlidingPanelContent = () => {
|
// 1. from the locally imported or
|
||||||
switch (slidingPanelContentId) {
|
// 2. from the CV-scraped version
|
||||||
case "CVMatches":
|
|
||||||
return (
|
|
||||||
<CVMatchesPanel
|
|
||||||
rawFileDetails={rawFileDetails}
|
|
||||||
inferredMetadata={inferredMetadata}
|
|
||||||
comicVineMatches={comicVineMatches}
|
|
||||||
comicObjectId={comicObjectId || _id}
|
|
||||||
queryClient={queryClient}
|
|
||||||
onMatchApplied={() => {
|
|
||||||
setVisible(false);
|
|
||||||
setActiveTab(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "editComicBookMetadata":
|
|
||||||
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<section className="container mx-auto">
|
||||||
<div className="section">
|
<div className="section">
|
||||||
{!isNil(data) && !isEmpty(data) && (
|
{!isNil(data) && !isEmpty(data) && (
|
||||||
<>
|
<>
|
||||||
@@ -196,17 +423,19 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
imageUrl={url}
|
imageUrl={url}
|
||||||
orientation={"cover-only"}
|
orientation={"cover-only"}
|
||||||
hasDetails={false}
|
hasDetails={false}
|
||||||
|
cardContainerStyle={{ maxWidth: "290px", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* raw file details */}
|
{/* raw file details */}
|
||||||
{!isUndefined(rawFileDetails) &&
|
{!isUndefined(rawFileDetails) &&
|
||||||
!isEmpty(rawFileDetails?.cover) && (
|
!isEmpty((rawFileDetails as any)?.cover) && (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<RawFileDetails
|
<RawFileDetails
|
||||||
data={{
|
data={{
|
||||||
rawFileDetails,
|
rawFileDetails: rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata: inferredMetadata,
|
||||||
createdAt,
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* action dropdown */}
|
{/* action dropdown */}
|
||||||
@@ -223,6 +452,25 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</RawFileDetails>
|
</RawFileDetails>
|
||||||
|
|
||||||
|
{/* <Modal
|
||||||
|
style={{ content: { marginTop: "2rem" } }}
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
onAfterOpen={afterOpenModal}
|
||||||
|
onRequestClose={closeModal}
|
||||||
|
contentLabel="Example Modal"
|
||||||
|
>
|
||||||
|
<button onClick={closeModal}>close</button>
|
||||||
|
{extractedComicBook && (
|
||||||
|
<ComicViewer
|
||||||
|
pages={extractedComicBook}
|
||||||
|
direction="ltr"
|
||||||
|
className={{
|
||||||
|
closeButton: "border: 1px solid red;",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal> */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,8 +479,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
<TabControls
|
<TabControls
|
||||||
filteredTabs={filteredTabs}
|
filteredTabs={filteredTabs}
|
||||||
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StyledSlidingPanel
|
<StyledSlidingPanel
|
||||||
@@ -241,7 +487,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
title={"Comic Vine Search Matches"}
|
title={"Comic Vine Search Matches"}
|
||||||
width={"600px"}
|
width={"600px"}
|
||||||
>
|
>
|
||||||
{renderSlidingPanelContent()}
|
{slidingPanelContentId !== "" &&
|
||||||
|
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||||
</StyledSlidingPanel>
|
</StyledSlidingPanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,40 +1,35 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
|
import axios from "axios";
|
||||||
|
|
||||||
export const ComicDetailContainer = (): ReactElement | null => {
|
export const ComicDetailContainer = (): ReactElement | null => {
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: comicBookDetailData,
|
data: comicBookDetailData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useGetComicByIdQuery(
|
} = useQuery({
|
||||||
{ id: comicObjectId! },
|
queryKey: ["comicBookMetadata"],
|
||||||
{ enabled: !!comicObjectId }
|
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} />
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
interface ComicVineMatchPanelProps {
|
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||||
props: {
|
const { comicObjectId, comicVineMatches } = comicVineData.props;
|
||||||
comicObjectId: string;
|
|
||||||
comicVineMatches: any[];
|
|
||||||
queryClient?: any;
|
|
||||||
onMatchApplied?: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Displays ComicVine search results or a status message while searching. */
|
|
||||||
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
|
|
||||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
|
|
||||||
const { comicvine } = useStore(
|
const { comicvine } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
comicvine: state.comicvine,
|
comicvine: state.comicvine,
|
||||||
@@ -28,8 +19,6 @@ export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPane
|
|||||||
<MatchResult
|
<MatchResult
|
||||||
matchData={comicVineMatches}
|
matchData={comicVineMatches}
|
||||||
comicObjectId={comicObjectId}
|
comicObjectId={comicObjectId}
|
||||||
queryClient={queryClient}
|
|
||||||
onMatchApplied={onMatchApplied}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
|||||||
*/
|
*/
|
||||||
const onDownloadTick = (data: DownloadTickData) => {
|
const onDownloadTick = (data: DownloadTickData) => {
|
||||||
// Compare numeric data.id to string bundleId
|
// Compare numeric data.id to string bundleId
|
||||||
|
console.log(data.id);
|
||||||
|
console.log(`bundleId is ${bundleId}`)
|
||||||
if (data.id === parseInt(bundleId, 10)) {
|
if (data.id === parseInt(bundleId, 10)) {
|
||||||
setTick(data);
|
setTick(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,55 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||||
import { Form, Field, FieldRenderProps } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
|
||||||
interface EditMetadataPanelProps {
|
export const EditMetadataPanel = (props): ReactElement => {
|
||||||
data: {
|
const validate = async () => {};
|
||||||
name?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
|
|
||||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
|
||||||
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
|
|
||||||
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
|
||||||
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Sliding panel form for manually editing comic metadata fields. */
|
|
||||||
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
|
|
||||||
const onSubmit = async () => {};
|
const onSubmit = async () => {};
|
||||||
|
|
||||||
|
const { data } = props;
|
||||||
|
|
||||||
|
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<AsyncSelectPaginate
|
||||||
|
{...input}
|
||||||
|
{...rest}
|
||||||
|
onChange={(value) => input.onChange(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<TextareaAutosize
|
||||||
|
{...input}
|
||||||
|
{...rest}
|
||||||
|
onChange={(value) => input.onChange(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// const rawFileDetails = useSelector(
|
||||||
|
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||||
|
// );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
mutators={{ ...arrayMutators }}
|
validate={validate}
|
||||||
|
mutators={{
|
||||||
|
...arrayMutators,
|
||||||
|
}}
|
||||||
render={({
|
render={({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
form: {
|
form: {
|
||||||
mutators: { push, pop },
|
mutators: { push, pop },
|
||||||
},
|
}, // injected from final-form-arrays above
|
||||||
|
pristine,
|
||||||
|
form,
|
||||||
|
submitting,
|
||||||
|
values,
|
||||||
}) => (
|
}) => (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Issue Name */}
|
{/* Issue Name */}
|
||||||
@@ -66,6 +80,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
<p className="text-xs">Do not enter the first zero</p>
|
<p className="text-xs">Do not enter the first zero</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
{/* year */}
|
||||||
<div className="text-sm">Issue Year</div>
|
<div className="text-sm">Issue Year</div>
|
||||||
<Field
|
<Field
|
||||||
name="issue_year"
|
name="issue_year"
|
||||||
@@ -85,6 +100,8 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* page count */}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<label className="text-sm">Description</label>
|
<label className="text-sm">Description</label>
|
||||||
@@ -96,7 +113,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
<div className="field-label">
|
<div className="field-label">
|
||||||
@@ -136,7 +153,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
{/* Publisher */}
|
{/* Publisher */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -207,7 +224,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr size="1" />
|
||||||
|
|
||||||
{/* team credits */}
|
{/* team credits */}
|
||||||
<div className="field is-horizontal">
|
<div className="field is-horizontal">
|
||||||
@@ -285,6 +302,7 @@ export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElemen
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import axios from "axios";
|
|||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
matchData: any;
|
matchData: any;
|
||||||
comicObjectId: string;
|
comicObjectId: string;
|
||||||
queryClient?: any;
|
|
||||||
onMatchApplied?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBrokenImage = (e) => {
|
const handleBrokenImage = (e) => {
|
||||||
@@ -18,33 +16,14 @@ const handleBrokenImage = (e) => {
|
|||||||
|
|
||||||
export const MatchResult = (props: MatchResultProps) => {
|
export const MatchResult = (props: MatchResultProps) => {
|
||||||
const applyCVMatch = async (match, comicObjectId) => {
|
const applyCVMatch = async (match, comicObjectId) => {
|
||||||
try {
|
return await axios.request({
|
||||||
const response = await axios.request({
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
method: "POST",
|
||||||
method: "POST",
|
data: {
|
||||||
data: {
|
match,
|
||||||
match,
|
comicObjectId,
|
||||||
comicObjectId,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidate and refetch the comic book metadata
|
|
||||||
if (props.queryClient) {
|
|
||||||
await props.queryClient.invalidateQueries({
|
|
||||||
queryKey: ["comicBookMetadata", comicObjectId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the callback to close panel and switch tabs
|
|
||||||
if (props.onMatchApplied) {
|
|
||||||
props.onMatchApplied();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error applying ComicVine match:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,113 +1,154 @@
|
|||||||
import React, { ReactElement, ReactNode } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { format, parseISO, isValid } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import {
|
|
||||||
RawFileDetails as RawFileDetailsType,
|
|
||||||
InferredMetadata,
|
|
||||||
} from "../../graphql/generated";
|
|
||||||
|
|
||||||
type RawFileDetailsProps = {
|
interface RawFileDetailsProps {
|
||||||
data?: {
|
data?: {
|
||||||
rawFileDetails?: RawFileDetailsType;
|
rawFileDetails?: {
|
||||||
inferredMetadata?: InferredMetadata;
|
containedIn?: string;
|
||||||
createdAt?: string;
|
name?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
path?: string;
|
||||||
|
extension?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
cover?: {
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
inferredMetadata?: {
|
||||||
|
issue?: {
|
||||||
|
year?: string;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
children?: ReactNode;
|
children?: any;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement | null => {
|
||||||
|
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||||
|
props.data || {};
|
||||||
|
if (!rawFileDetails) return null;
|
||||||
|
|
||||||
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
|
||||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
|
||||||
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-2xl ml-5">
|
<div className="max-w-2xl ml-5">
|
||||||
<div className="px-4 sm:px-6">
|
{/* Title */}
|
||||||
|
<div className="px-4 sm:px-6 mb-6">
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
<span className="text-xl">{rawFileDetails?.name}</span>
|
<span className="text-xl font-semibold">{rawFileDetails?.name}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-5 sm:px-6">
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
{/* File Binary Details Section */}
|
||||||
<div className="sm:col-span-1">
|
<div className="mb-8 px-4 pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<div className="mb-4">
|
||||||
Raw File Details
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
|
||||||
</dt>
|
<i className="icon-[solar--document-bold-duotone] w-5 h-5"></i>
|
||||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
File Binary Details
|
||||||
{rawFileDetails?.containedIn}
|
</h3>
|
||||||
{"/"}
|
</div>
|
||||||
{rawFileDetails?.name}
|
<div className="pl-6">
|
||||||
{rawFileDetails?.extension}
|
<dl className="space-y-4">
|
||||||
</dd>
|
<div>
|
||||||
</div>
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
<div className="sm:col-span-1">
|
File Path
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
</dt>
|
||||||
Inferred Issue Metadata
|
<dd className="text-sm text-gray-900 dark:text-gray-300 font-mono break-all">
|
||||||
</dt>
|
{rawFileDetails?.containedIn}/{rawFileDetails?.name}{rawFileDetails?.extension}
|
||||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
</dd>
|
||||||
Series Name: {inferredMetadata?.issue?.name}
|
</div>
|
||||||
{!isEmpty(inferredMetadata?.issue?.number) ? (
|
|
||||||
<span className="tag is-primary is-light">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{inferredMetadata?.issue?.number}
|
<div>
|
||||||
</span>
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
) : null}
|
MIME Type
|
||||||
</dd>
|
</dt>
|
||||||
</div>
|
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||||
<div className="sm:col-span-1">
|
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
MIMEType
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
|
||||||
{/* File extension */}
|
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
|
||||||
<span className="pt-1">
|
|
||||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
|
||||||
{rawFileDetails?.mimeType}
|
{rawFileDetails?.mimeType}
|
||||||
</span>
|
</dd>
|
||||||
</span>
|
</div>
|
||||||
</dd>
|
|
||||||
</div>
|
<div>
|
||||||
<div className="sm:col-span-1">
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
File Size
|
||||||
File Size
|
</dt>
|
||||||
</dt>
|
<dd className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
|
||||||
{/* size */}
|
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
|
||||||
<span className="pr-1 pt-1">
|
|
||||||
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : 'N/A'}
|
||||||
|
</dd>
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
</div>
|
||||||
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
|
</div>
|
||||||
</span>
|
</dl>
|
||||||
</span>
|
</div>
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
Import Details
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
|
||||||
{createdAt && isValid(parseISO(createdAt)) ? (
|
|
||||||
<>
|
|
||||||
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
|
|
||||||
{format(parseISO(createdAt), "h aaaa")}
|
|
||||||
</>
|
|
||||||
) : "N/A"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
Actions
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Import Details Section */}
|
||||||
|
<div className="mb-8 px-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<i className="icon-[solar--import-bold-duotone] w-5 h-5"></i>
|
||||||
|
Import Details
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6">
|
||||||
|
<dl className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Imported On
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{created_at ? format(parseISO(created_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Last Updated
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{updated_at ? format(parseISO(updated_at), "dd MMMM, yyyy 'at' h:mm aaaa") : 'N/A'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inferredMetadata?.issue && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Inferred Metadata
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="font-medium">{inferredMetadata.issue.name}</span>
|
||||||
|
{!isEmpty(inferredMetadata.issue.number) && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
#{inferredMetadata.issue.number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Section */}
|
||||||
|
{props.children && (
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wide">
|
||||||
|
Actions
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div>{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
|
||||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
|
||||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
|
||||||
import { RawFileDetails } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type InferredIssue = {
|
|
||||||
name?: string;
|
|
||||||
number?: number;
|
|
||||||
year?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CVMatchesPanelProps = {
|
|
||||||
rawFileDetails?: RawFileDetails;
|
|
||||||
inferredMetadata: {
|
|
||||||
issue?: InferredIssue;
|
|
||||||
};
|
|
||||||
comicVineMatches: any[];
|
|
||||||
comicObjectId: string;
|
|
||||||
queryClient: any;
|
|
||||||
onMatchApplied: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
|
||||||
rawFileDetails,
|
|
||||||
inferredMetadata,
|
|
||||||
comicVineMatches,
|
|
||||||
comicObjectId,
|
|
||||||
queryClient,
|
|
||||||
onMatchApplied,
|
|
||||||
}) => (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<ComicVineSearchForm data={rawFileDetails} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
|
||||||
<p className="">Searching for:</p>
|
|
||||||
{inferredMetadata.issue ? (
|
|
||||||
<>
|
|
||||||
<span className="">{inferredMetadata.issue?.name} </span>
|
|
||||||
<span className=""> # {inferredMetadata.issue?.number} </span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ComicVineMatchPanel
|
|
||||||
props={{
|
|
||||||
comicVineMatches,
|
|
||||||
comicObjectId,
|
|
||||||
queryClient,
|
|
||||||
onMatchApplied,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
type EditMetadataPanelWrapperProps = {
|
|
||||||
rawFileDetails?: RawFileDetails;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
|
||||||
rawFileDetails,
|
|
||||||
}) => <EditMetadataPanel data={rawFileDetails} />;
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import React, { ReactElement, Suspense, useState } from "react";
|
import React, { ReactElement, useState } from "react";
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
export const TabControls = (props): ReactElement => {
|
export const TabControls = (props): ReactElement => {
|
||||||
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
const { filteredTabs, downloadCount } = props;
|
||||||
const [active, setActive] = useState(filteredTabs[0].id);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -18,12 +14,12 @@ export const TabControls = (props): ReactElement => {
|
|||||||
<a
|
<a
|
||||||
key={id}
|
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 ${
|
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||||
currentActive === id
|
active === id
|
||||||
? "border-b border-cyan-50 dark:text-slate-200"
|
? "border-b border-cyan-50 dark:text-slate-200"
|
||||||
: "border-b border-transparent"
|
: "border-b border-transparent"
|
||||||
}`}
|
}`}
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
onClick={() => handleSetActive(id)}
|
onClick={() => setActive(id)}
|
||||||
>
|
>
|
||||||
{/* Downloads tab and count badge */}
|
{/* Downloads tab and count badge */}
|
||||||
<>
|
<>
|
||||||
@@ -47,11 +43,9 @@ export const TabControls = (props): ReactElement => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense>
|
{filteredTabs.map(({ id, content }) => {
|
||||||
{filteredTabs.map(({ id, content }) => {
|
return active === id ? content : null;
|
||||||
return currentActive === id ? content : null;
|
})}
|
||||||
})}
|
|
||||||
</Suspense>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,18 @@ import { useStore } from "../../../store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
||||||
|
|
||||||
export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
interface ArchiveOperationsProps {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArchiveOperations = (props: ArchiveOperationsProps): ReactElement => {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
|
|
||||||
const getSocket = useStore((state) => state.getSocket);
|
const { getSocket } = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
getSocket: state.getSocket,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// sliding panel config
|
// sliding panel config
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
@@ -37,14 +45,14 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
|||||||
// Listen to the uncompression complete event and orchestrate the final payload
|
// Listen to the uncompression complete event and orchestrate the final payload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
const handleUncompressionComplete = (data: any) => {
|
const handleUncompressionComplete = (data: any) => {
|
||||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||||
|
|
||||||
|
// Cleanup listener on unmount
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||||
};
|
};
|
||||||
@@ -77,7 +85,8 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling could be added here if needed
|
console.error("Error fetching uncompressed archive:", error);
|
||||||
|
// Handle error if necessary
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUncompressedArchive();
|
fetchUncompressedArchive();
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { isUndefined } from "lodash";
|
import { isUndefined } from "lodash";
|
||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
export const ComicInfoXML = (data): ReactElement => {
|
||||||
const { json } = data;
|
const { json } = data;
|
||||||
return (
|
return (
|
||||||
<div className="flex w-3/4">
|
<div className="flex md:w-4/5 lg:w-78">
|
||||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
|
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
|
||||||
<dt>
|
<dt>
|
||||||
<p className="text-lg">{json.series?.[0]}</p>
|
<p className="text-lg">{json.series[0]}</p>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm">
|
<dd className="text-sm">
|
||||||
published by{" "}
|
published by{" "}
|
||||||
<span className="underline">
|
<span className="underline">
|
||||||
{json.publisher?.[0]}
|
{json.publisher[0]}
|
||||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -30,20 +30,18 @@ export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
)}
|
)}
|
||||||
{/* Genre */}
|
<dd className="my-2">
|
||||||
{!isUndefined(json.genre) && (
|
{/* 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="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">
|
||||||
<span className="pr-1 pt-1">
|
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-slate-500 dark:text-slate-900">
|
|
||||||
{json.genre[0]}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
|
||||||
)}
|
<span className="text-slate-500 dark:text-slate-900">
|
||||||
|
{json.genre[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<dd className="my-1">
|
<dd className="my-1">
|
||||||
@@ -54,14 +52,12 @@ export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
{!isUndefined(json.notes) && (
|
<dd>
|
||||||
<dd>
|
{/* Notes */}
|
||||||
{/* Notes */}
|
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
{json.notes[0]}
|
||||||
{json.notes[0]}
|
</span>
|
||||||
</span>
|
</dd>
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import prettyBytes from "pretty-bytes";
|
|||||||
|
|
||||||
export const TorrentDownloads = (props) => {
|
export const TorrentDownloads = (props) => {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
|
console.log(Object.values(data));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data.map(({ torrent }) => {
|
{data.map(({ torrent }) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const TorrentSearchPanel = (props) => {
|
|||||||
mutationFn: async (newTorrent) =>
|
mutationFn: async (newTorrent) =>
|
||||||
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
// Torrent added successfully
|
console.log(data);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const searchIndexer = (values) => {
|
const searchIndexer = (values) => {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { StylesConfig } from "react-select";
|
|
||||||
|
|
||||||
export interface ActionOption {
|
|
||||||
value: string;
|
|
||||||
label: React.ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CVMatchLabel = (
|
|
||||||
<span className="inline-flex flex-row items-center gap-2">
|
|
||||||
<div className="w-6 h-6">
|
|
||||||
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
|
|
||||||
</div>
|
|
||||||
<div>Match on ComicVine</div>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const editLabel = (
|
|
||||||
<span className="inline-flex flex-row items-center gap-2">
|
|
||||||
<div className="w-6 h-6">
|
|
||||||
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
|
|
||||||
</div>
|
|
||||||
<div>Edit Metadata</div>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteLabel = (
|
|
||||||
<span className="inline-flex flex-row items-center gap-2">
|
|
||||||
<div className="w-6 h-6">
|
|
||||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
|
|
||||||
</div>
|
|
||||||
<div>Delete Comic</div>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const actionOptions: ActionOption[] = [
|
|
||||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
|
||||||
{ value: "edit-metdata", label: editLabel },
|
|
||||||
{ value: "delete-comic", label: deleteLabel },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const customStyles: StylesConfig<ActionOption, false> = {
|
|
||||||
menu: (base: any) => ({
|
|
||||||
...base,
|
|
||||||
backgroundColor: "rgb(156, 163, 175)",
|
|
||||||
}),
|
|
||||||
placeholder: (base: any) => ({
|
|
||||||
...base,
|
|
||||||
color: "black",
|
|
||||||
}),
|
|
||||||
option: (base: any, { isFocused }: any) => ({
|
|
||||||
...base,
|
|
||||||
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
|
|
||||||
}),
|
|
||||||
singleValue: (base: any) => ({
|
|
||||||
...base,
|
|
||||||
paddingTop: "0.4rem",
|
|
||||||
}),
|
|
||||||
control: (base: any) => ({
|
|
||||||
...base,
|
|
||||||
backgroundColor: "rgb(156, 163, 175)",
|
|
||||||
color: "black",
|
|
||||||
border: "1px solid rgb(156, 163, 175)",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import React, { lazy } from "react";
|
|
||||||
import { isNil, isEmpty } from "lodash";
|
|
||||||
|
|
||||||
const VolumeInformation = lazy(() => import("./Tabs/VolumeInformation").then(m => ({ default: m.VolumeInformation })));
|
|
||||||
const ComicInfoXML = lazy(() => import("./Tabs/ComicInfoXML").then(m => ({ default: m.ComicInfoXML })));
|
|
||||||
const ArchiveOperations = lazy(() => import("./Tabs/ArchiveOperations").then(m => ({ default: m.ArchiveOperations })));
|
|
||||||
const AcquisitionPanel = lazy(() => import("./AcquisitionPanel"));
|
|
||||||
const TorrentSearchPanel = lazy(() => import("./TorrentSearchPanel"));
|
|
||||||
const DownloadsPanel = lazy(() => import("./DownloadsPanel"));
|
|
||||||
|
|
||||||
interface TabConfig {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
icon: React.ReactElement;
|
|
||||||
content: React.ReactElement | null;
|
|
||||||
shouldShow: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabConfigParams {
|
|
||||||
data: any;
|
|
||||||
comicInfo: any;
|
|
||||||
isComicBookMetadataAvailable: boolean;
|
|
||||||
areRawFileDetailsAvailable: boolean;
|
|
||||||
airDCPPQuery: any;
|
|
||||||
comicObjectId: string;
|
|
||||||
userSettings: any;
|
|
||||||
issueName: string;
|
|
||||||
acquisition?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createTabConfig = ({
|
|
||||||
data,
|
|
||||||
comicInfo,
|
|
||||||
isComicBookMetadataAvailable,
|
|
||||||
areRawFileDetailsAvailable,
|
|
||||||
airDCPPQuery,
|
|
||||||
comicObjectId,
|
|
||||||
userSettings,
|
|
||||||
issueName,
|
|
||||||
acquisition,
|
|
||||||
}: TabConfigParams): TabConfig[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Volume Information",
|
|
||||||
icon: (
|
|
||||||
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
|
|
||||||
),
|
|
||||||
content: isComicBookMetadataAvailable ? (
|
|
||||||
<VolumeInformation data={data} key={1} />
|
|
||||||
) : null,
|
|
||||||
shouldShow: isComicBookMetadataAvailable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "ComicInfo.xml",
|
|
||||||
icon: (
|
|
||||||
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
|
|
||||||
),
|
|
||||||
content: (
|
|
||||||
<div key={2}>
|
|
||||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
shouldShow: !isEmpty(comicInfo),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
icon: (
|
|
||||||
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
|
|
||||||
),
|
|
||||||
name: "Archive Operations",
|
|
||||||
content: <ArchiveOperations data={data} key={3} />,
|
|
||||||
shouldShow: areRawFileDetailsAvailable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
icon: (
|
|
||||||
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
|
|
||||||
),
|
|
||||||
name: "DC++ Search",
|
|
||||||
content: (
|
|
||||||
<AcquisitionPanel
|
|
||||||
query={airDCPPQuery}
|
|
||||||
comicObjectId={comicObjectId}
|
|
||||||
comicObject={data}
|
|
||||||
settings={userSettings}
|
|
||||||
key={4}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
shouldShow: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
icon: (
|
|
||||||
<span className="inline-flex flex-row">
|
|
||||||
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
name: "Torrent Search",
|
|
||||||
content: <TorrentSearchPanel comicObjectId={comicObjectId} issueName={issueName} />,
|
|
||||||
shouldShow: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: "Downloads",
|
|
||||||
icon: (
|
|
||||||
<>
|
|
||||||
{(acquisition?.directconnect?.downloads?.length || 0) +
|
|
||||||
(acquisition?.torrent?.length || 0)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
content:
|
|
||||||
!isNil(data) && !isEmpty(data) ? (
|
|
||||||
<DownloadsPanel key={5} />
|
|
||||||
) : (
|
|
||||||
<div className="column is-three-fifths">
|
|
||||||
<article className="message is-info">
|
|
||||||
<div className="message-body is-size-6 is-family-secondary">
|
|
||||||
AirDC++ is not configured. Please configure it in{" "}
|
|
||||||
<code>Settings</code>.
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
shouldShow: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
import { isNil, isUndefined, isEmpty } from "lodash";
|
|
||||||
import { refineQuery } from "filename-parser";
|
|
||||||
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
|
||||||
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type ComicVineMatch = {
|
|
||||||
score: number;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComicVineSearchQuery = {
|
|
||||||
inferredIssueDetails: {
|
|
||||||
name: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ComicVineMetadata = {
|
|
||||||
name?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useComicVineMatching = () => {
|
|
||||||
const [comicVineMatches, setComicVineMatches] = useState<ComicVineMatch[]>([]);
|
|
||||||
|
|
||||||
const fetchComicVineMatches = async (
|
|
||||||
searchPayload: any,
|
|
||||||
issueSearchQuery: ComicVineSearchQuery,
|
|
||||||
seriesSearchQuery: ComicVineSearchQuery,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const response = await axios({
|
|
||||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
format: "json",
|
|
||||||
// hack
|
|
||||||
query: issueSearchQuery.inferredIssueDetails.name
|
|
||||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
|
||||||
.trim(),
|
|
||||||
limit: "100",
|
|
||||||
page: 1,
|
|
||||||
resources: "volume",
|
|
||||||
scorerConfiguration: {
|
|
||||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
|
||||||
},
|
|
||||||
rawFileDetails: searchPayload,
|
|
||||||
},
|
|
||||||
transformResponse: (r) => {
|
|
||||||
const matches = JSON.parse(r);
|
|
||||||
return matches;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let matches: ComicVineMatch[] = [];
|
|
||||||
if (!isNil(response.data.results) && response.data.results.length === 1) {
|
|
||||||
matches = response.data.results;
|
|
||||||
} else {
|
|
||||||
matches = response.data.map((match: ComicVineMatch) => match);
|
|
||||||
}
|
|
||||||
const scoredMatches = matches.sort((a: ComicVineMatch, b: ComicVineMatch) => b.score - a.score);
|
|
||||||
setComicVineMatches(scoredMatches);
|
|
||||||
} catch (err) {
|
|
||||||
// Error handling could be added here if needed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareAndFetchMatches = (
|
|
||||||
rawFileDetails: RawFileDetailsType | undefined,
|
|
||||||
comicvine: ComicVineMetadata | undefined,
|
|
||||||
) => {
|
|
||||||
let seriesSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
|
||||||
let issueSearchQuery: ComicVineSearchQuery = {} as ComicVineSearchQuery;
|
|
||||||
|
|
||||||
if (!isUndefined(rawFileDetails) && rawFileDetails.name) {
|
|
||||||
issueSearchQuery = refineQuery(rawFileDetails.name) as ComicVineSearchQuery;
|
|
||||||
} else if (!isEmpty(comicvine) && comicvine?.name) {
|
|
||||||
issueSearchQuery = refineQuery(comicvine.name) as ComicVineSearchQuery;
|
|
||||||
}
|
|
||||||
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
comicVineMatches,
|
|
||||||
prepareAndFetchMatches,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,72 +1,82 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
import ZeroState from "./ZeroState";
|
||||||
import { RecentlyImported } from "./RecentlyImported";
|
import { RecentlyImported } from "./RecentlyImported";
|
||||||
import { WantedComicsList } from "./WantedComicsList";
|
import { WantedComicsList } from "./WantedComicsList";
|
||||||
import { VolumeGroups } from "./VolumeGroups";
|
import { VolumeGroups } from "./VolumeGroups";
|
||||||
import { LibraryStatistics } from "./LibraryStatistics";
|
import { LibraryStatistics } from "./LibraryStatistics";
|
||||||
import { PullList } from "./PullList";
|
import { PullList } from "./PullList";
|
||||||
import {
|
import { useQuery } from "@tanstack/react-query";
|
||||||
useGetRecentComicsQuery,
|
import axios from "axios";
|
||||||
useGetWantedComicsQuery,
|
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
useGetVolumeGroupsQuery,
|
|
||||||
useGetLibraryStatisticsQuery
|
|
||||||
} from "../../graphql/generated";
|
|
||||||
|
|
||||||
export const Dashboard = (): ReactElement => {
|
export const Dashboard = (): ReactElement => {
|
||||||
// Use GraphQL for recent comics
|
const { data: recentComics } = useQuery({
|
||||||
const { data: recentComicsData, error: recentComicsError } = useGetRecentComicsQuery(
|
queryFn: async () =>
|
||||||
{ limit: 5 },
|
await axios({
|
||||||
{ refetchOnWindowFocus: false }
|
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"],
|
||||||
|
});
|
||||||
|
|
||||||
// Wanted Comics - using GraphQL
|
const { data: statistics } = useQuery({
|
||||||
const { data: wantedComicsData, error: wantedComicsError } = useGetWantedComicsQuery(
|
queryFn: async () =>
|
||||||
{
|
await axios({
|
||||||
paginationOptions: {
|
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||||
page: 1,
|
method: "GET",
|
||||||
limit: 5,
|
}),
|
||||||
sort: '{"updatedAt": -1}'
|
queryKey: ["libraryStatistics"],
|
||||||
},
|
});
|
||||||
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 (
|
return (
|
||||||
<>
|
<div className="container mx-auto max-w-full">
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<PullList />
|
||||||
<PullList />
|
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
|
||||||
{recentComics.length > 0 && <RecentlyImported comics={recentComics} />}
|
{/* Wanted comics */}
|
||||||
{/* Wanted comics */}
|
<WantedComicsList comics={wantedComics?.data?.docs} />
|
||||||
<WantedComicsList comics={wantedComics} />
|
{/* Library Statistics */}
|
||||||
{/* Library Statistics */}
|
{statistics && <LibraryStatistics stats={statistics?.data} />}
|
||||||
{statistics && <LibraryStatistics stats={statistics} />}
|
{/* Volume groups */}
|
||||||
{/* Volume groups */}
|
<VolumeGroups volumeGroups={volumeGroups?.data} />
|
||||||
<VolumeGroups volumeGroups={volumeGroups} />
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +1,100 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement, useEffect } from "react";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { isEmpty, isUndefined, map } from "lodash";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
|
|
||||||
comicDirectorySize: DirectorySize;
|
|
||||||
comicsMissingFiles: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Props for {@link LibraryStatistics}. */
|
|
||||||
interface LibraryStatisticsProps {
|
|
||||||
stats: Stats | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a snapshot of library metrics: total comic files, tagging coverage,
|
|
||||||
* file-type breakdown, and the publisher with the most issues.
|
|
||||||
*
|
|
||||||
* Returns `null` when `stats` is absent or the statistics array is empty.
|
|
||||||
*/
|
|
||||||
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
|
|
||||||
if (!stats) return null;
|
|
||||||
|
|
||||||
const facet = stats.statistics?.[0];
|
|
||||||
if (!facet) return null;
|
|
||||||
|
|
||||||
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary } = facet;
|
|
||||||
const topPublisher = publisherWithMostComicsInLibrary?.[0];
|
|
||||||
|
|
||||||
|
export const LibraryStatistics = (
|
||||||
|
props: ILibraryStatisticsProps,
|
||||||
|
): ReactElement => {
|
||||||
|
const { stats } = props;
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Header
|
<Header
|
||||||
headerContent="Your Library In Numbers"
|
headerContent="Your Library In Numbers"
|
||||||
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
|
subHeaderContent={
|
||||||
|
<span className="text-md">A brief snapshot of your library.</span>
|
||||||
|
}
|
||||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-row gap-5">
|
<div className="mt-3">
|
||||||
{/* Total records in database */}
|
<div className="flex flex-row gap-5">
|
||||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||||
<dt className="text-lg font-medium text-gray-500">In database</dt>
|
<dt className="text-lg font-medium text-gray-500">Library size</dt>
|
||||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||||
{stats.totalDocuments} comics
|
{props.stats.totalDocuments} files
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
<dd>
|
||||||
|
<span className="text-2xl text-green-600">
|
||||||
{/* Missing files */}
|
{props.stats.comicDirectorySize &&
|
||||||
<div className="flex flex-col rounded-lg bg-card-missing px-4 py-6 text-center">
|
prettyBytes(props.stats.comicDirectorySize)}
|
||||||
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
|
</span>
|
||||||
<dd className="text-3xl text-red-600 md:text-5xl">
|
|
||||||
{stats.comicsMissingFiles}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disk space consumed */}
|
|
||||||
{stats.comicDirectorySize.totalSizeInGB != null && (
|
|
||||||
<div className="flex flex-col rounded-lg bg-card-info px-4 py-6 text-center">
|
|
||||||
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
|
|
||||||
<dd className="text-3xl text-gray-700 md:text-5xl">
|
|
||||||
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* comicinfo and comicvine tagged issues */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!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}
|
||||||
|
</span>{" "}
|
||||||
|
tagged with ComicVine
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!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}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
|
||||||
|
with ComicInfo.xml
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tagging coverage */}
|
<div className="">
|
||||||
<div className="flex flex-col gap-4">
|
{!isUndefined(props.stats.statistics) &&
|
||||||
{issues && issues.length > 0 && (
|
!isEmpty(props.stats.statistics[0].fileTypes) &&
|
||||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
map(props.stats.statistics[0].fileTypes, (fileType, idx) => {
|
||||||
<span className="text-xl text-gray-700">{issues.length}</span>
|
return (
|
||||||
tagged with ComicVine
|
<span
|
||||||
</div>
|
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"
|
||||||
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
|
>
|
||||||
<div className="flex flex-col h-fit rounded-lg bg-card-info px-4 py-3 text-center">
|
{fileType.data.length} {fileType._id}
|
||||||
<span className="text-xl text-gray-700">{issuesWithComicInfoXML.length}</span>
|
</span>
|
||||||
with ComicInfo.xml
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* file types */}
|
||||||
|
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
|
||||||
|
{/* publisher with most issues */}
|
||||||
|
{!isUndefined(props.stats.statistics) &&
|
||||||
|
!isEmpty(
|
||||||
|
props.stats.statistics[0].publisherWithMostComicsInLibrary[0],
|
||||||
|
) && (
|
||||||
|
<>
|
||||||
|
<span className="">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0]._id
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{" has the most issues "}
|
||||||
|
<span className="">
|
||||||
|
{
|
||||||
|
props.stats.statistics[0]
|
||||||
|
.publisherWithMostComicsInLibrary[0].count
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File-type breakdown */}
|
|
||||||
{fileTypes && fileTypes.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{fileTypes.map((ft) => (
|
|
||||||
<span
|
|
||||||
key={ft.id}
|
|
||||||
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-card-info px-4 py-3 text-center text-gray-700"
|
|
||||||
>
|
|
||||||
{ft.data.length} {ft.id}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Publisher with most issues */}
|
|
||||||
{topPublisher && (
|
|
||||||
<div className="flex flex-col h-fit text-lg rounded-lg bg-card-info px-4 py-3 text-gray-700">
|
|
||||||
<span>{topPublisher.id}</span>
|
|
||||||
{" has the most issues "}
|
|
||||||
<span>{topPublisher.count}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,85 @@
|
|||||||
import React, { ReactElement, useState } from "react";
|
import React, { ReactElement, useState, useEffect } from "react";
|
||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
|
import { importToDB } from "../../actions/fileops.actions";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import rateLimiter from "axios-rate-limit";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
import { setupCache } from "axios-cache-interceptor";
|
||||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Form } from "react-final-form";
|
import "keen-slider/keen-slider.min.css";
|
||||||
|
import { useKeenSlider } from "keen-slider/react";
|
||||||
|
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
|
||||||
|
import { Field, Form } from "react-final-form";
|
||||||
import DatePickerDialog from "../shared/DatePicker";
|
import DatePickerDialog from "../shared/DatePicker";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { LocgMetadata, useGetWeeklyPullListQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
interface PullListProps {
|
type PullListProps = {
|
||||||
issues?: LocgMetadata[];
|
issues: any;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const http = rateLimiter(axios.create(), {
|
||||||
|
maxRequests: 1,
|
||||||
|
perMilliseconds: 1000,
|
||||||
|
maxRPS: 1,
|
||||||
|
});
|
||||||
|
const cachedAxios = setupCache(axios);
|
||||||
export const PullList = (): ReactElement => {
|
export const PullList = (): ReactElement => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// datepicker
|
// datepicker
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const [inputValue, setInputValue] = useState<string>(
|
const [inputValue, setInputValue] = useState<string>(
|
||||||
format(date, "yyyy/M/dd"),
|
format(date, "yyyy/M/dd"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// embla carousel
|
// Responsive slides per view
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
const [slidesPerView, setSlidesPerView] = useState(1);
|
||||||
loop: false,
|
|
||||||
align: "start",
|
|
||||||
containScroll: "trimSnaps",
|
|
||||||
slidesToScroll: 1,
|
// keen slider
|
||||||
|
const [sliderRef, instanceRef] = useKeenSlider({
|
||||||
|
loop: true,
|
||||||
|
mode: "free-snap",
|
||||||
|
slides: {
|
||||||
|
perView: slidesPerView,
|
||||||
|
spacing: 15,
|
||||||
|
},
|
||||||
|
slideChanged() {
|
||||||
|
console.log("slide changed");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update slider when slidesPerView changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceRef.current) {
|
||||||
|
instanceRef.current.update({
|
||||||
|
slides: {
|
||||||
|
perView: slidesPerView,
|
||||||
|
spacing: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [slidesPerView, instanceRef]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pullListData,
|
data: pullList,
|
||||||
refetch,
|
refetch,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useGetWeeklyPullListQuery({
|
} = useQuery({
|
||||||
input: {
|
queryFn: async (): any =>
|
||||||
startDate: inputValue,
|
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||||
pageSize: 15,
|
method: "get",
|
||||||
currentPage: 1,
|
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
|
||||||
},
|
}),
|
||||||
});
|
queryKey: ["pullList", inputValue],
|
||||||
|
|
||||||
// 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 = () => {
|
const next = () => {
|
||||||
// sliderRef.slickNext();
|
// sliderRef.slickNext();
|
||||||
@@ -92,89 +90,89 @@ export const PullList = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header
|
<div className="content">
|
||||||
headerContent="Discover"
|
<div className="mx-auto">
|
||||||
subHeaderContent={
|
<Header
|
||||||
<span className="text-md">
|
headerContent="Discover"
|
||||||
Pull List aggregated for the week from{" "}
|
subHeaderContent={
|
||||||
<span className="underline">
|
<span className="text-md">
|
||||||
<a href="https://leagueofcomicgeeks.com">
|
Pull List aggregated for the week from{" "}
|
||||||
League Of Comic Geeks
|
<span className="underline">
|
||||||
</a>
|
<a href="https://leagueofcomicgeeks.com">
|
||||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
League Of Comic Geeks
|
||||||
|
</a>
|
||||||
|
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
}
|
||||||
}
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
link="/pull-list/all/"
|
||||||
link="/pull-list/all/"
|
/>
|
||||||
/>
|
<div className="flex flex-row gap-5 mb-3">
|
||||||
<div className="flex flex-row gap-5 mb-3">
|
{/* select week */}
|
||||||
{/* select week */}
|
<div className="flex flex-row gap-4 my-3">
|
||||||
<div className="flex flex-row gap-4 my-3">
|
<Form
|
||||||
<Form
|
onSubmit={() => {}}
|
||||||
onSubmit={() => {}}
|
render={({ handleSubmit }) => (
|
||||||
render={({ handleSubmit }) => (
|
<form>
|
||||||
<form>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
{/* week selection for pull list */}
|
||||||
{/* week selection for pull list */}
|
<DatePickerDialog
|
||||||
<DatePickerDialog
|
inputValue={inputValue}
|
||||||
inputValue={inputValue}
|
setter={setInputValue}
|
||||||
setter={setInputValue}
|
/>
|
||||||
/>
|
{inputValue && (
|
||||||
{inputValue && (
|
<div className="text-sm">
|
||||||
<div className="text-sm">
|
Showing pull list for{" "}
|
||||||
Showing pull list for{" "}
|
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
{inputValue}
|
||||||
{inputValue}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-lvw -mr-4 sm:-mr-6 lg:-mr-8">
|
|
||||||
{isSuccess && !isLoading && (
|
|
||||||
<div className="overflow-hidden" ref={emblaRef}>
|
|
||||||
<div className="flex">
|
|
||||||
{map(pullList?.data.result, (issue: LocgMetadata, idx: number) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
orientation={"vertical-2"}
|
|
||||||
imageUrl={issue.cover || undefined}
|
|
||||||
hasDetails
|
|
||||||
title={ellipsize(issue.name || 'Unknown', 25)}
|
|
||||||
>
|
|
||||||
<div className="px-1">
|
|
||||||
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
|
||||||
{issue.publisher || 'Unknown Publisher'}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row justify-end">
|
|
||||||
<button
|
|
||||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
|
||||||
onClick={() => addToLibrary({ sourceName: "locg", metadata: { locg: issue } })}
|
|
||||||
>
|
|
||||||
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
|
|
||||||
Want
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
)}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isLoading && <div>Loading...</div>}
|
|
||||||
{isError && <div>An error occurred while retrieving the pull list.</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSuccess && !isLoading && (
|
||||||
|
<div ref={sliderRef} className="keen-slider">
|
||||||
|
{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.issueName, 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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? <div>Loading...</div> : null}
|
||||||
|
{isError ? (
|
||||||
|
<div>An error occurred while retrieving the pull list.</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,82 +4,67 @@ import { Link } from "react-router-dom";
|
|||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import {
|
||||||
|
determineCoverFile,
|
||||||
|
determineExternalMetadata,
|
||||||
|
} from "../../shared/utils/metadata.utils";
|
||||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
|
||||||
import { GetRecentComicsQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type RecentlyImportedProps = {
|
type RecentlyImportedProps = {
|
||||||
comics: GetRecentComicsQuery['comics']['comics'];
|
comics: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecentlyImported = (
|
export const RecentlyImported = (
|
||||||
{ comics }: RecentlyImportedProps,
|
comics: RecentlyImportedProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
// embla carousel
|
console.log(comics);
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
||||||
loop: false,
|
|
||||||
align: "start",
|
|
||||||
containScroll: "trimSnaps",
|
|
||||||
slidesToScroll: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header
|
<div className="mx-auto" style={{ maxWidth: '1400px' }}>
|
||||||
headerContent="Recently Imported"
|
<Header
|
||||||
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
headerContent="Recently Imported"
|
||||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
subHeaderContent="Recent Library activity such as imports, tagging, etc."
|
||||||
/>
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
/>
|
||||||
<div className="overflow-hidden" ref={emblaRef}>
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-5 gap-6 mt-3">
|
||||||
<div className="flex">
|
{comics?.comics.map(
|
||||||
{comics?.map((comic, idx) => {
|
(
|
||||||
const {
|
{
|
||||||
id,
|
_id,
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
sourcedMetadata,
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
canonicalMetadata,
|
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
importStatus,
|
wanted: { source } = {},
|
||||||
} = comic;
|
},
|
||||||
|
idx,
|
||||||
// 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({
|
const { issueName, url } = determineCoverFile({
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
comicvine,
|
comicvine,
|
||||||
comicInfo,
|
comicInfo,
|
||||||
locg,
|
locg,
|
||||||
});
|
});
|
||||||
|
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
},
|
||||||
|
);
|
||||||
const isComicVineMetadataAvailable =
|
const isComicVineMetadataAvailable =
|
||||||
!isUndefined(comicvine) &&
|
!isUndefined(comicvine) &&
|
||||||
!isUndefined(comicvine.volumeInformation);
|
!isUndefined(comicvine.volumeInformation);
|
||||||
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
|
|
||||||
const isMissingFile = importStatus?.isRawFileMissing === true;
|
|
||||||
const cardState = isMissingFile
|
|
||||||
? "missing"
|
|
||||||
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
|
orientation="vertical-2"
|
||||||
key={idx}
|
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]"
|
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
|
||||||
|
title={inferredMetadata.issue.name}
|
||||||
|
hasDetails
|
||||||
>
|
>
|
||||||
<Card
|
|
||||||
orientation="vertical-2"
|
|
||||||
imageUrl={url}
|
|
||||||
title={inferredMetadata?.issue?.name}
|
|
||||||
hasDetails
|
|
||||||
cardState={cardState}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<dd className="text-sm my-1 flex flex-row gap-1">
|
<dd className="text-sm my-1 flex flex-row gap-1">
|
||||||
{/* Issue number */}
|
{/* Issue number */}
|
||||||
@@ -88,7 +73,7 @@ export const RecentlyImported = (
|
|||||||
<i className="icon-[solar--hashtag-outline]"></i>
|
<i className="icon-[solar--hashtag-outline]"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-md text-slate-900">
|
<span className="text-md text-slate-900">
|
||||||
{inferredMetadata?.issue?.number}
|
{inferredMetadata.issue.number}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/* File extension */}
|
{/* File extension */}
|
||||||
@@ -98,7 +83,7 @@ export const RecentlyImported = (
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
{rawFileDetails?.extension}
|
{rawFileDetails.extension}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{/* Uncompressed status */}
|
{/* Uncompressed status */}
|
||||||
@@ -116,27 +101,31 @@ export const RecentlyImported = (
|
|||||||
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
|
||||||
{/* ComicInfo.xml presence */}
|
{/* ComicInfo.xml presence */}
|
||||||
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
|
||||||
<div className="mt-1">
|
<div mt-1>
|
||||||
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-gray-500 dark:text-white-300"></i>
|
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* ComicVine metadata presence */}
|
{/* ComicVine metadata presence */}
|
||||||
{isComicVineMetadataAvailable && (
|
{isComicVineMetadataAvailable && (
|
||||||
<span className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
<span className="w-7 h-7">
|
||||||
<img
|
<img
|
||||||
src="/src/client/assets/img/cvlogo.svg"
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
alt={"ComicVine metadata detected."}
|
alt={"ComicVine metadata detected."}
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Raw file presence */}
|
||||||
|
{isNil(rawFileDetails) && (
|
||||||
|
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
|
||||||
|
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,79 +4,59 @@ import ellipsize from "ellipsize";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
|
||||||
import { GetVolumeGroupsQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type VolumeGroupsProps = {
|
export const VolumeGroups = (props): ReactElement => {
|
||||||
volumeGroups?: GetVolumeGroupsQuery['getComicBookGroups'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VolumeGroups = (props: VolumeGroupsProps): ReactElement => {
|
|
||||||
// Till mongo gives us back the deduplicated results with the ObjectId
|
// Till mongo gives us back the deduplicated results with the ObjectId
|
||||||
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToVolumes = (row: any) => {
|
const navigateToVolumes = (row) => {
|
||||||
navigate(`/volumes/all`);
|
navigate(`/volumes/all`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// embla carousel
|
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
||||||
loop: false,
|
|
||||||
align: "start",
|
|
||||||
containScroll: "trimSnaps",
|
|
||||||
slidesToScroll: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<section>
|
||||||
<Header
|
<Header
|
||||||
headerContent="Volumes"
|
headerContent="Volumes"
|
||||||
subHeaderContent={<>Based on ComicVine Volume information</>}
|
subHeaderContent="Based on ComicVine Volume information"
|
||||||
iconClassNames="fa-solid fa-binoculars mr-2"
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
link={"/volumes"}
|
link={"/volumes"}
|
||||||
/>
|
/>
|
||||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
<div className="grid grid-cols-5 gap-6 mt-3">
|
||||||
<div className="overflow-hidden" ref={emblaRef}>
|
{map(deduplicatedGroups, (data) => {
|
||||||
<div className="flex">
|
return (
|
||||||
{map(deduplicatedGroups, (data) => {
|
<div className="max-w-sm mx-auto" key={data._id}>
|
||||||
return (
|
<Card
|
||||||
<div
|
orientation="vertical-2"
|
||||||
key={data.id}
|
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]"
|
imageUrl={data.volumes.image.small_url}
|
||||||
>
|
hasDetails
|
||||||
<Card
|
>
|
||||||
orientation="vertical-2"
|
<div className="py-3">
|
||||||
imageUrl={data.volumes?.image?.small_url || undefined}
|
<div className="text-sm">
|
||||||
hasDetails
|
<Link to={`/volume/details/${data._id}`}>
|
||||||
>
|
{ellipsize(data.volumes.name, 48)}
|
||||||
<div className="py-3">
|
</Link>
|
||||||
<div className="text-sm">
|
</div>
|
||||||
<Link to={`/volume/details/${data.id}`}>
|
{/* issue count */}
|
||||||
{ellipsize(data.volumes?.name || 'Unknown', 48)}
|
<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">
|
||||||
</Link>
|
<span className="pr-1 pt-1">
|
||||||
</div>
|
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
{/* issue count */}
|
</span>
|
||||||
<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">
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
{data.volumes?.count_of_issues || 0} issues
|
{data.volumes.count_of_issues} issues
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
|
||||||
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
|
||||||
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</Card>
|
||||||
})}
|
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
|
||||||
</div>
|
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
|
||||||
</div>
|
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
|||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import Header from "../shared/Header";
|
import Header from "../shared/Header";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { GetWantedComicsQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type WantedComicsListProps = {
|
type WantedComicsListProps = {
|
||||||
comics?: GetWantedComicsQuery['getComicBooks']['docs'];
|
comics: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WantedComicsList = ({
|
export const WantedComicsList = ({
|
||||||
@@ -30,107 +29,114 @@ export const WantedComicsList = ({
|
|||||||
<div>
|
<div>
|
||||||
<Header
|
<Header
|
||||||
headerContent="Wanted Comics"
|
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"
|
iconClassNames="fa-solid fa-binoculars mr-2"
|
||||||
link={"/wanted"}
|
link={"/wanted"}
|
||||||
/>
|
/>
|
||||||
<div className="-mr-10 sm:-mr-17 lg:-mr-29 xl:-mr-36 2xl:-mr-42 mt-3">
|
<div className="overflow-hidden -mr-4 sm:-mr-8 lg:-mr-16 xl:-mr-20 2xl:-mr-24 mt-3">
|
||||||
<div className="overflow-hidden" ref={emblaRef}>
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{map(
|
{map(
|
||||||
comics,
|
comics,
|
||||||
(comic) => {
|
(
|
||||||
const {
|
{
|
||||||
id,
|
_id,
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
sourcedMetadata,
|
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||||
} = comic;
|
wanted,
|
||||||
|
},
|
||||||
|
idx,
|
||||||
|
) => {
|
||||||
|
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||||
|
const consolidatedComicMetadata = {
|
||||||
|
rawFileDetails,
|
||||||
|
comicvine,
|
||||||
|
comicInfo,
|
||||||
|
locg,
|
||||||
|
};
|
||||||
|
|
||||||
// Parse sourced metadata (GraphQL returns as strings)
|
const {
|
||||||
const comicvine = typeof sourcedMetadata?.comicvine === 'string'
|
issueName,
|
||||||
? JSON.parse(sourcedMetadata.comicvine)
|
url,
|
||||||
: sourcedMetadata?.comicvine;
|
publisher = null,
|
||||||
const comicInfo = typeof sourcedMetadata?.comicInfo === 'string'
|
} = determineCoverFile(consolidatedComicMetadata);
|
||||||
? JSON.parse(sourcedMetadata.comicInfo)
|
const titleElement = (
|
||||||
: sourcedMetadata?.comicInfo;
|
<Link to={"/comic/details/" + _id}>
|
||||||
const locg = sourcedMetadata?.locg;
|
{ellipsize(issueName, 20)}
|
||||||
|
<p>{publisher}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex-[0_0_200px] min-w-0 sm:flex-[0_0_220px] md:flex-[0_0_240px] lg:flex-[0_0_260px] xl:flex-[0_0_280px] pr-[15px]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
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>
|
||||||
|
|
||||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
const consolidatedComicMetadata = {
|
{
|
||||||
rawFileDetails,
|
detectIssueTypes(comicvine.description)
|
||||||
comicvine,
|
.displayName
|
||||||
comicInfo,
|
}
|
||||||
locg,
|
</span>
|
||||||
};
|
</span>
|
||||||
|
|
||||||
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>
|
</div>
|
||||||
{/* comicVine metadata presence */}
|
) : null}
|
||||||
{isComicBookMetadataAvailable && (
|
{/* issues marked as wanted, part of this volume */}
|
||||||
<img
|
{wanted?.markEntireVolumeWanted ? (
|
||||||
src="/src/client/assets/img/cvlogo.svg"
|
<div className="text-sm">sagla volume pahije</div>
|
||||||
alt={"ComicVine metadata detected."}
|
) : (
|
||||||
className="inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0 object-contain"
|
<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">
|
||||||
{!isEmpty(locg) && (
|
<i className="icon-[solar--documents-bold-duotone] w-5 h-5"></i>
|
||||||
<img
|
</span>
|
||||||
src="/src/client/assets/img/locglogo.svg"
|
|
||||||
className="w-7 h-7"
|
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||||
/>
|
{wanted.issues.length}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</span>
|
||||||
</Card>
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
);
|
</Card>
|
||||||
},
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
},
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,12 +57,13 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
|
|||||||
}, [issueBundles]);
|
}, [issueBundles]);
|
||||||
|
|
||||||
return !isNil(bundles) ? (
|
return !isNil(bundles) ? (
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="container">
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h1 className="title">Downloads</h1>
|
<h1 className="title">Downloads</h1>
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
<div className="column is-half">
|
<div className="column is-half">
|
||||||
{bundles.map((bundle, idx) => {
|
{bundles.map((bundle, idx) => {
|
||||||
|
console.log(bundle);
|
||||||
return (
|
return (
|
||||||
<div key={idx}>
|
<div key={idx}>
|
||||||
<MetadataPanel
|
<MetadataPanel
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { Import } from './Import';
|
|
||||||
|
|
||||||
// Mock axios
|
|
||||||
jest.mock('axios');
|
|
||||||
const mockedAxios = axios as jest.MockedFunction<any>;
|
|
||||||
|
|
||||||
// Mock zustand store
|
|
||||||
const mockGetSocket = jest.fn();
|
|
||||||
const mockDisconnectSocket = jest.fn();
|
|
||||||
const mockSetStatus = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../store', () => ({
|
|
||||||
useStore: jest.fn((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'drained',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock socket.io-client
|
|
||||||
const mockSocket = {
|
|
||||||
on: jest.fn(),
|
|
||||||
off: jest.fn(),
|
|
||||||
emit: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockGetSocket.mockReturnValue(mockSocket);
|
|
||||||
|
|
||||||
// Helper function to create a wrapper with QueryClient
|
|
||||||
const createWrapper = () => {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Import Component - Numerical Indices', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display numerical indices in the Past Imports table', async () => {
|
|
||||||
// Mock API response with 3 import sessions
|
|
||||||
const mockData = [
|
|
||||||
{
|
|
||||||
sessionId: 'session-1',
|
|
||||||
earliestTimestamp: '2024-01-01T10:00:00Z',
|
|
||||||
completedJobs: 5,
|
|
||||||
failedJobs: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sessionId: 'session-2',
|
|
||||||
earliestTimestamp: '2024-01-02T10:00:00Z',
|
|
||||||
completedJobs: 3,
|
|
||||||
failedJobs: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sessionId: 'session-3',
|
|
||||||
earliestTimestamp: '2024-01-03T10:00:00Z',
|
|
||||||
completedJobs: 8,
|
|
||||||
failedJobs: 2
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(axios as any).mockResolvedValue({ data: mockData });
|
|
||||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Wait for the "Past Imports" heading to appear
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that the "#" column header exists
|
|
||||||
expect(screen.getByText('#')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify that numerical indices (1, 2, 3) are displayed in the first column of each row
|
|
||||||
const rows = screen.getAllByRole('row');
|
|
||||||
// Skip header row (index 0), check data rows
|
|
||||||
expect(rows[1].querySelectorAll('td')[0]).toHaveTextContent('1');
|
|
||||||
expect(rows[2].querySelectorAll('td')[0]).toHaveTextContent('2');
|
|
||||||
expect(rows[3].querySelectorAll('td')[0]).toHaveTextContent('3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display correct indices for larger datasets', async () => {
|
|
||||||
// Mock API response with 10 import sessions
|
|
||||||
const mockData = Array.from({ length: 10 }, (_, i) => ({
|
|
||||||
sessionId: `session-${i + 1}`,
|
|
||||||
earliestTimestamp: `2024-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
|
|
||||||
completedJobs: i + 1,
|
|
||||||
failedJobs: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
(axios as any).mockResolvedValue({ data: mockData });
|
|
||||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Past Imports')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify indices 1 through 10 are present in the first column
|
|
||||||
const rows = screen.getAllByRole('row');
|
|
||||||
// Skip header row (index 0)
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
const row = rows[i];
|
|
||||||
const cells = row.querySelectorAll('td');
|
|
||||||
expect(cells[0]).toHaveTextContent(i.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Import Component - Button Visibility', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
(axios as any).mockResolvedValue({ data: [] });
|
|
||||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show Start Import button when queue status is drained', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'drained',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify Pause and Resume buttons are NOT visible
|
|
||||||
expect(screen.queryByText('Pause')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show Start Import button when queue status is undefined', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: undefined,
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Start Import')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide Start Import button and show Pause button when queue is running', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'running',
|
|
||||||
successfulJobCount: 5,
|
|
||||||
failedJobCount: 1,
|
|
||||||
mostRecentImport: 'Comic #123',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Pause')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify Import Activity section is visible
|
|
||||||
expect(screen.getByText('Import Activity')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('5')).toBeInTheDocument(); // successful count
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument(); // failed count
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide Start Import button and show Resume button when queue is paused', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'paused',
|
|
||||||
successfulJobCount: 3,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: 'Comic #456',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('Start Import')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Resume')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Import Component - SessionId and Socket Reconnection', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.useFakeTimers();
|
|
||||||
localStorage.clear();
|
|
||||||
(axios as any).mockResolvedValue({ data: [] });
|
|
||||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should clear sessionId and reconnect socket when starting import after queue is drained', async () => {
|
|
||||||
// Setup: Set old sessionId in localStorage
|
|
||||||
localStorage.setItem('sessionId', 'old-session-id');
|
|
||||||
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'drained',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Click the "Start Import" button
|
|
||||||
const startButton = await screen.findByText('Start Import');
|
|
||||||
fireEvent.click(startButton);
|
|
||||||
|
|
||||||
// Verify sessionId is cleared immediately
|
|
||||||
expect(localStorage.getItem('sessionId')).toBeNull();
|
|
||||||
|
|
||||||
// Verify disconnectSocket is called
|
|
||||||
expect(mockDisconnectSocket).toHaveBeenCalledWith('/');
|
|
||||||
|
|
||||||
// Fast-forward 100ms
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify getSocket is called after 100ms
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGetSocket).toHaveBeenCalledWith('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fast-forward another 500ms
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify initiateImport is called and status is set to running
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(axios.request).toHaveBeenCalledWith({
|
|
||||||
url: 'http://localhost:3000/api/library/newImport',
|
|
||||||
method: 'POST',
|
|
||||||
data: { sessionId: null },
|
|
||||||
});
|
|
||||||
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT clear sessionId when starting import with undefined status', async () => {
|
|
||||||
// Setup: Set existing sessionId in localStorage
|
|
||||||
localStorage.setItem('sessionId', 'existing-session-id');
|
|
||||||
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: undefined,
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Click the "Start Import" button
|
|
||||||
const startButton = await screen.findByText('Start Import');
|
|
||||||
fireEvent.click(startButton);
|
|
||||||
|
|
||||||
// Verify sessionId is NOT cleared
|
|
||||||
expect(localStorage.getItem('sessionId')).toBe('existing-session-id');
|
|
||||||
|
|
||||||
// Verify disconnectSocket is NOT called
|
|
||||||
expect(mockDisconnectSocket).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Verify status is set to running immediately
|
|
||||||
expect(mockSetStatus).toHaveBeenCalledWith('running');
|
|
||||||
|
|
||||||
// Verify initiateImport is called immediately (no delay)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(axios.request).toHaveBeenCalledWith({
|
|
||||||
url: 'http://localhost:3000/api/library/newImport',
|
|
||||||
method: 'POST',
|
|
||||||
data: { sessionId: 'existing-session-id' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Import Component - Real-time Updates', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
(axios as any).mockResolvedValue({ data: [] });
|
|
||||||
(axios.request as jest.Mock) = jest.fn().mockResolvedValue({ data: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should refetch table data when LS_COVER_EXTRACTED event is received', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'running',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Wait for component to mount and socket listeners to be attached
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the event handler that was registered
|
|
||||||
const coverExtractedHandler = mockSocket.on.mock.calls.find(
|
|
||||||
(call) => call[0] === 'LS_COVER_EXTRACTED'
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// Clear previous axios calls
|
|
||||||
(axios as any).mockClear();
|
|
||||||
|
|
||||||
// Simulate the socket event
|
|
||||||
if (coverExtractedHandler) {
|
|
||||||
coverExtractedHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the API is called again (refetch)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(axios).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should refetch table data when LS_IMPORT_QUEUE_DRAINED event is received', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'running',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Wait for component to mount and socket listeners to be attached
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSocket.on).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the event handler that was registered
|
|
||||||
const queueDrainedHandler = mockSocket.on.mock.calls.find(
|
|
||||||
(call) => call[0] === 'LS_IMPORT_QUEUE_DRAINED'
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
// Clear previous axios calls
|
|
||||||
(axios as any).mockClear();
|
|
||||||
|
|
||||||
// Simulate the socket event
|
|
||||||
if (queueDrainedHandler) {
|
|
||||||
queueDrainedHandler();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the API is called again (refetch)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(axios).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
url: 'http://localhost:3000/api/jobqueue/getJobResultStatistics',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should cleanup socket listeners on unmount', async () => {
|
|
||||||
const { useStore } = require('../../store');
|
|
||||||
useStore.mockImplementation((selector: any) =>
|
|
||||||
selector({
|
|
||||||
importJobQueue: {
|
|
||||||
status: 'drained',
|
|
||||||
successfulJobCount: 0,
|
|
||||||
failedJobCount: 0,
|
|
||||||
mostRecentImport: '',
|
|
||||||
setStatus: mockSetStatus,
|
|
||||||
},
|
|
||||||
getSocket: mockGetSocket,
|
|
||||||
disconnectSocket: mockDisconnectSocket,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { unmount } = render(<Import path="/test" />, { wrapper: createWrapper() });
|
|
||||||
|
|
||||||
// Wait for socket listeners to be attached
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSocket.on).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unmount the component
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
// Verify that socket listeners are removed
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith('LS_COVER_EXTRACTED', expect.any(Function));
|
|
||||||
expect(mockSocket.off).toHaveBeenCalledWith('LS_IMPORT_QUEUE_DRAINED', expect.any(Function));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,136 +1,201 @@
|
|||||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
import React, { ReactElement, useCallback, useEffect, useRef } from "react";
|
||||||
|
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { isEmpty } from "lodash";
|
import Loader from "react-loader-spinner";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useGetJobResultStatisticsQuery } from "../../graphql/generated";
|
|
||||||
import { RealTimeImportStats } from "./RealTimeImportStats";
|
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
|
||||||
|
|
||||||
export const Import = (): ReactElement => {
|
interface IProps {
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
matches?: unknown;
|
||||||
|
fetchComicMetadata?: any;
|
||||||
|
path: string;
|
||||||
|
covers?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to facilitate the import of comics to the ThreeTwo library
|
||||||
|
*
|
||||||
|
* @param x - The first input number
|
||||||
|
* @param y - The second input number
|
||||||
|
* @returns The arithmetic mean of `x` and `y`
|
||||||
|
*
|
||||||
|
* @beta
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const Import = (props: IProps): ReactElement => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { importJobQueue, getSocket, disconnectSocket } = useStore(
|
const { importJobQueue, getSocket, setQueryClientRef } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
importJobQueue: state.importJobQueue,
|
importJobQueue: state.importJobQueue,
|
||||||
getSocket: state.getSocket,
|
getSocket: state.getSocket,
|
||||||
disconnectSocket: state.disconnectSocket,
|
setQueryClientRef: state.setQueryClientRef,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const previousResultCountRef = useRef<number>(0);
|
||||||
|
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Force re-import mutation - re-imports all files regardless of import status
|
// Set the queryClient reference in the store so socket events can use it
|
||||||
const { mutate: forceReImport, isPending: isForceReImporting } = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: async () => {
|
setQueryClientRef({ current: queryClient });
|
||||||
const sessionId = localStorage.getItem("sessionId") || "";
|
}, [queryClient, setQueryClientRef]);
|
||||||
return await axios.request({
|
|
||||||
url: `http://localhost:3000/api/library/forceReImport`,
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
|
const { mutate: initiateImport } = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
await axios.request({
|
||||||
|
url: `http://localhost:3000/api/library/newImport`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { sessionId },
|
data: { sessionId },
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
onSuccess: (response) => {
|
|
||||||
console.log("Force re-import initiated:", response.data);
|
|
||||||
importJobQueue.setStatus("running");
|
|
||||||
setImportError(null);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("Failed to start force re-import:", error);
|
|
||||||
setImportError(error?.response?.data?.message || error?.message || "Failed to start force re-import. Please try again.");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useGetJobResultStatisticsQuery();
|
const { data, isError, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ["allImportJobResults"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios({
|
||||||
|
method: "GET",
|
||||||
|
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
|
||||||
|
params: {
|
||||||
|
_t: Date.now(), // Cache buster
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the result count
|
||||||
|
if (response.data?.length) {
|
||||||
|
previousResultCountRef.current = response.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 0, // Always consider data stale
|
||||||
|
gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
|
||||||
|
// Poll every 5 seconds when import is running
|
||||||
|
refetchInterval: importJobQueue.status === "running" || importJobQueue.status === "paused" ? 5000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
const importSession = useImportSessionStatus();
|
// Listen for import queue drained event to refresh the table
|
||||||
const hasActiveSession = importSession.isActive;
|
|
||||||
const wasComplete = useRef(false);
|
|
||||||
|
|
||||||
// React to importSession.isComplete rather than socket events — more reliable
|
|
||||||
// since it's derived from the actual GraphQL state, not a raw socket event.
|
|
||||||
useEffect(() => {
|
|
||||||
if (importSession.isComplete && !wasComplete.current) {
|
|
||||||
wasComplete.current = true;
|
|
||||||
// Small delay so the backend has time to commit job result stats
|
|
||||||
setTimeout(() => {
|
|
||||||
// Invalidate the cache to force a fresh fetch of job result statistics
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
||||||
refetch();
|
|
||||||
}, 1500);
|
|
||||||
importJobQueue.setStatus("drained");
|
|
||||||
} else if (!importSession.isComplete) {
|
|
||||||
wasComplete.current = false;
|
|
||||||
}
|
|
||||||
}, [importSession.isComplete, refetch, importJobQueue, queryClient]);
|
|
||||||
|
|
||||||
// Listen to socket events to update Past Imports table in real-time
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket("/");
|
const socket = getSocket("/");
|
||||||
|
|
||||||
const handleImportCompleted = () => {
|
|
||||||
console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports");
|
|
||||||
// Small delay to ensure backend has committed the job results
|
|
||||||
setTimeout(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueueDrained = () => {
|
const handleQueueDrained = () => {
|
||||||
console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports");
|
const initialCount = previousResultCountRef.current;
|
||||||
// Small delay to ensure backend has committed the job results
|
let attempts = 0;
|
||||||
setTimeout(() => {
|
const maxAttempts = 20; // Poll for up to 20 seconds
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] });
|
|
||||||
}, 1500);
|
// Clear any existing polling interval
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every second until we see new data or hit max attempts
|
||||||
|
pollingIntervalRef.current = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const result = await refetch();
|
||||||
|
const newCount = result.data?.data?.length || 0;
|
||||||
|
|
||||||
|
if (newCount > initialCount) {
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
} else if (attempts >= maxAttempts) {
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
pollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted);
|
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
||||||
};
|
if (pollingIntervalRef.current) {
|
||||||
}, [getSocket, queryClient]);
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles force re-import - re-imports all files to fix indexing issues
|
|
||||||
*/
|
|
||||||
const handleForceReImport = async () => {
|
|
||||||
setImportError(null);
|
|
||||||
|
|
||||||
// Check for active session before starting using definitive status
|
|
||||||
if (hasActiveSession) {
|
|
||||||
setImportError(
|
|
||||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.confirm(
|
|
||||||
"This will re-import ALL files in your library folder, even those already imported. " +
|
|
||||||
"This can help fix Elasticsearch indexing issues. Continue?"
|
|
||||||
)) {
|
|
||||||
if (importJobQueue.status === "drained") {
|
|
||||||
localStorage.removeItem("sessionId");
|
|
||||||
disconnectSocket("/");
|
|
||||||
setTimeout(() => {
|
|
||||||
getSocket("/");
|
|
||||||
setTimeout(() => {
|
|
||||||
forceReImport();
|
|
||||||
}, 500);
|
|
||||||
}, 100);
|
|
||||||
} else {
|
|
||||||
forceReImport();
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, [getSocket, queryClient, refetch]);
|
||||||
|
|
||||||
|
const toggleQueue = (queueAction: string, queueStatus: string) => {
|
||||||
|
const socket = getSocket("/");
|
||||||
|
socket.emit(
|
||||||
|
"call",
|
||||||
|
"socket.setQueueStatus",
|
||||||
|
{
|
||||||
|
queueAction,
|
||||||
|
queueStatus,
|
||||||
|
},
|
||||||
|
(data: any) => console.log(data),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Method to render import job queue pause/resume controls on the UI
|
||||||
|
*
|
||||||
|
* @param status The `string` status (either `"pause"` or `"resume"`)
|
||||||
|
* @returns ReactElement A `<button/>` that toggles queue status
|
||||||
|
* @remarks Sets the global `importJobQueue.status` state upon toggling
|
||||||
|
*/
|
||||||
|
const renderQueueControls = (status: string): ReactElement | null => {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => {
|
||||||
|
toggleQueue("pause", "paused");
|
||||||
|
importJobQueue.setStatus("paused");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-md">Pause</span>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--pause-bold]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "paused":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
|
onClick={() => {
|
||||||
|
toggleQueue("resume", "running");
|
||||||
|
importJobQueue.setStatus("running");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-md">Resume</span>
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<i className="h-5 w-5 icon-[solar--play-bold]"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "drained":
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
@@ -165,61 +230,70 @@ export const Import = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* Import Statistics */}
|
<div className="my-4">
|
||||||
<div className="my-6 max-w-screen-lg">
|
{importJobQueue.status === "drained" ||
|
||||||
<RealTimeImportStats />
|
(importJobQueue.status === undefined && (
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{importError && (
|
|
||||||
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
|
||||||
Import Error
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
{importError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setImportError(null)}
|
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"
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
onClick={() => {
|
||||||
|
initiateImport();
|
||||||
|
importJobQueue.setStatus("running");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="w-5 h-5">
|
<span className="text-md">Start Import</span>
|
||||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
<span className="w-6 h-6">
|
||||||
|
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity */}
|
||||||
|
{(importJobQueue.status === "running" ||
|
||||||
|
importJobQueue.status === "paused") && (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center my-5 max-w-screen-lg">
|
||||||
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
|
Import Activity
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||||
|
</span>
|
||||||
|
<div className="mt-5 flex flex-col gap-4 sm:mt-0 sm:flex-row sm:items-center">
|
||||||
|
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-2">
|
||||||
|
{/* Successful import counts */}
|
||||||
|
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
|
||||||
|
<dd className="text-3xl text-green-600 md:text-5xl">
|
||||||
|
{importJobQueue.successfulJobCount}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-lg font-medium text-gray-500">
|
||||||
|
imported
|
||||||
|
</dt>
|
||||||
|
</div>
|
||||||
|
{/* Failed job counts */}
|
||||||
|
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
|
||||||
|
<dd className="text-3xl text-red-600 md:text-5xl">
|
||||||
|
{importJobQueue.failedJobCount}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-lg font-medium text-gray-500">
|
||||||
|
failed
|
||||||
|
</dt>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col dark:text-slate-200 text-slate-400">
|
||||||
|
<dd>{renderQueueControls(importJobQueue.status)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex">
|
||||||
|
<span className="mt-2 dark:text-slate-200 text-slate-400">
|
||||||
|
Imported: <span>{importJobQueue.mostRecentImport}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Force Re-Import Button - always shown when no import is running */}
|
{/* Past imports */}
|
||||||
{!hasActiveSession &&
|
{!isLoading && !isEmpty(data?.data) && (
|
||||||
(importJobQueue.status === "drained" || importJobQueue.status === undefined) && (
|
|
||||||
<div className="my-6 max-w-screen-lg">
|
|
||||||
<button
|
|
||||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-orange-400 dark:border-orange-200 bg-orange-200 px-5 py-3 text-gray-700 hover:bg-transparent hover:text-orange-600 focus:outline-none focus:ring active:text-orange-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
onClick={handleForceReImport}
|
|
||||||
disabled={isForceReImporting || hasActiveSession}
|
|
||||||
title="Re-import all files to fix Elasticsearch indexing issues"
|
|
||||||
>
|
|
||||||
<span className="text-md font-medium">
|
|
||||||
{isForceReImporting ? "Starting Re-Import..." : "Force Re-Import All Files"}
|
|
||||||
</span>
|
|
||||||
<span className="w-6 h-6">
|
|
||||||
<i className="h-6 w-6 icon-[solar--refresh-bold-duotone]"></i>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Import activity is now shown in the RealTimeImportStats component above */}
|
|
||||||
|
|
||||||
{!isLoading && !isEmpty(data?.getJobResultStatistics) && (
|
|
||||||
<div className="max-w-screen-lg">
|
<div className="max-w-screen-lg">
|
||||||
<span className="flex items-center mt-6">
|
<span className="flex items-center mt-6">
|
||||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||||
@@ -232,9 +306,6 @@ export const Import = (): ReactElement => {
|
|||||||
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
|
<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">
|
<thead className="ltr:text-left rtl:text-right">
|
||||||
<tr>
|
<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">
|
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
|
||||||
Time Started
|
Time Started
|
||||||
</th>
|
</th>
|
||||||
@@ -251,19 +322,14 @@ export const Import = (): ReactElement => {
|
|||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{data?.getJobResultStatistics.map((jobResult: any, index: number) => {
|
{data?.data.map((jobResult: any, id: number) => {
|
||||||
return (
|
return (
|
||||||
<tr key={index}>
|
<tr key={id}>
|
||||||
<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">
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
{jobResult.earliestTimestamp && !isNaN(parseInt(jobResult.earliestTimestamp))
|
{format(
|
||||||
? format(
|
new Date(jobResult.earliestTimestamp),
|
||||||
new Date(parseInt(jobResult.earliestTimestamp)),
|
"EEEE, hh:mma, do LLLL Y",
|
||||||
"EEEE, hh:mma, do LLLL y",
|
)}
|
||||||
)
|
|
||||||
: "N/A"}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
<span className="tag is-warning">
|
<span className="tag is-warning">
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
import { ReactElement, useEffect, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
useGetImportStatisticsQuery,
|
|
||||||
useGetWantedComicsQuery,
|
|
||||||
useStartIncrementalImportMutation,
|
|
||||||
} from "../../graphql/generated";
|
|
||||||
import { useStore } from "../../store";
|
|
||||||
import { useShallow } from "zustand/react/shallow";
|
|
||||||
import { useImportSessionStatus } from "../../hooks/useImportSessionStatus";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import statistics with card-based layout and progress bar.
|
|
||||||
* Three states: pre-import (idle), importing (active), and post-import (complete).
|
|
||||||
* Also surfaces missing files detected by the file watcher.
|
|
||||||
*/
|
|
||||||
export const RealTimeImportStats = (): ReactElement => {
|
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
|
||||||
const [detectedFile, setDetectedFile] = useState<string | null>(null);
|
|
||||||
const [socketImport, setSocketImport] = useState<{
|
|
||||||
active: boolean;
|
|
||||||
completed: number;
|
|
||||||
total: number;
|
|
||||||
failed: number;
|
|
||||||
} | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { getSocket, disconnectSocket, importJobQueue } = useStore(
|
|
||||||
useShallow((state) => ({
|
|
||||||
getSocket: state.getSocket,
|
|
||||||
disconnectSocket: state.disconnectSocket,
|
|
||||||
importJobQueue: state.importJobQueue,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: importStats, isLoading } = useGetImportStatisticsQuery(
|
|
||||||
{},
|
|
||||||
{ refetchOnWindowFocus: false, refetchInterval: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = importStats?.getImportStatistics?.stats;
|
|
||||||
|
|
||||||
// File list for the detail panel — only fetched when there are missing files
|
|
||||||
const { data: missingComicsData } = useGetWantedComicsQuery(
|
|
||||||
{
|
|
||||||
paginationOptions: { limit: 3, page: 1 },
|
|
||||||
predicate: { "importStatus.isRawFileMissing": true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: false,
|
|
||||||
enabled: (stats?.missingFiles ?? 0) > 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const missingDocs = missingComicsData?.getComicBooks?.docs ?? [];
|
|
||||||
|
|
||||||
const getMissingComicLabel = (comic: any): string => {
|
|
||||||
const series =
|
|
||||||
comic.canonicalMetadata?.series?.value ??
|
|
||||||
comic.inferredMetadata?.issue?.name;
|
|
||||||
const issueNum =
|
|
||||||
comic.canonicalMetadata?.issueNumber?.value ??
|
|
||||||
comic.inferredMetadata?.issue?.number;
|
|
||||||
if (series && issueNum) return `${series} #${issueNum}`;
|
|
||||||
if (series) return series;
|
|
||||||
return comic.rawFileDetails?.name ?? comic.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const importSession = useImportSessionStatus();
|
|
||||||
|
|
||||||
const { mutate: startIncrementalImport, isPending: isStartingImport } =
|
|
||||||
useStartIncrementalImportMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.startIncrementalImport.success) {
|
|
||||||
importJobQueue.setStatus("running");
|
|
||||||
setImportError(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
setImportError(
|
|
||||||
error?.message || "Failed to start import. Please try again.",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasNewFiles = stats && stats.newFiles > 0;
|
|
||||||
const missingCount = stats?.missingFiles ?? 0;
|
|
||||||
|
|
||||||
// LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion.
|
|
||||||
// Invalidating GetImportStatistics covers: total files, imported, new files, and missing count.
|
|
||||||
// Invalidating GetWantedComics refreshes the missing file name list in the detail panel.
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = getSocket("/");
|
|
||||||
|
|
||||||
const handleStatsChange = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetImportStatistics"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["GetWantedComics"] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileDetected = (payload: { filePath: string }) => {
|
|
||||||
handleStatsChange();
|
|
||||||
const name = payload.filePath.split("/").pop() ?? payload.filePath;
|
|
||||||
setDetectedFile(name);
|
|
||||||
setTimeout(() => setDetectedFile(null), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportStarted = () => {
|
|
||||||
setSocketImport({ active: true, completed: 0, total: 0, failed: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCoverExtracted = (payload: {
|
|
||||||
completedJobCount: number;
|
|
||||||
totalJobCount: number;
|
|
||||||
importResult: unknown;
|
|
||||||
}) => {
|
|
||||||
setSocketImport((prev) => ({
|
|
||||||
active: true,
|
|
||||||
completed: payload.completedJobCount,
|
|
||||||
total: payload.totalJobCount,
|
|
||||||
failed: prev?.failed ?? 0,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCoverExtractionFailed = (payload: {
|
|
||||||
failedJobCount: number;
|
|
||||||
importResult: unknown;
|
|
||||||
}) => {
|
|
||||||
setSocketImport((prev) =>
|
|
||||||
prev ? { ...prev, failed: payload.failedJobCount } : null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueueDrained = () => {
|
|
||||||
setSocketImport((prev) => (prev ? { ...prev, active: false } : null));
|
|
||||||
handleStatsChange();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("LS_LIBRARY_STATS", handleStatsChange);
|
|
||||||
socket.on("LS_FILES_MISSING", handleStatsChange);
|
|
||||||
socket.on("LS_FILE_DETECTED", handleFileDetected);
|
|
||||||
socket.on("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
|
||||||
socket.on("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.on("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
|
||||||
socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off("LS_LIBRARY_STATS", handleStatsChange);
|
|
||||||
socket.off("LS_FILES_MISSING", handleStatsChange);
|
|
||||||
socket.off("LS_FILE_DETECTED", handleFileDetected);
|
|
||||||
socket.off("LS_INCREMENTAL_IMPORT_STARTED", handleImportStarted);
|
|
||||||
socket.off("LS_COVER_EXTRACTED", handleCoverExtracted);
|
|
||||||
socket.off("LS_COVER_EXTRACTION_FAILED", handleCoverExtractionFailed);
|
|
||||||
socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained);
|
|
||||||
};
|
|
||||||
}, [getSocket, queryClient]);
|
|
||||||
|
|
||||||
const handleStartImport = async () => {
|
|
||||||
setImportError(null);
|
|
||||||
|
|
||||||
if (importSession.isActive) {
|
|
||||||
setImportError(
|
|
||||||
`Cannot start import: An import session "${importSession.sessionId}" is already active. Please wait for it to complete.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (importJobQueue.status === "drained") {
|
|
||||||
localStorage.removeItem("sessionId");
|
|
||||||
disconnectSocket("/");
|
|
||||||
setTimeout(() => {
|
|
||||||
getSocket("/");
|
|
||||||
setTimeout(() => {
|
|
||||||
const sessionId = localStorage.getItem("sessionId") || "";
|
|
||||||
startIncrementalImport({ sessionId });
|
|
||||||
}, 500);
|
|
||||||
}, 100);
|
|
||||||
} else {
|
|
||||||
const sessionId = localStorage.getItem("sessionId") || "";
|
|
||||||
startIncrementalImport({ sessionId });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading || !stats) {
|
|
||||||
return <div className="text-gray-500 dark:text-gray-400">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFirstImport = stats.alreadyImported === 0;
|
|
||||||
const buttonText = isFirstImport
|
|
||||||
? `Start Import (${stats.newFiles} files)`
|
|
||||||
: `Start Incremental Import (${stats.newFiles} new files)`;
|
|
||||||
|
|
||||||
// Determine what to show in each card based on current phase
|
|
||||||
const sessionStats = importSession.stats;
|
|
||||||
const hasSessionStats = importSession.isActive && sessionStats !== null;
|
|
||||||
|
|
||||||
const totalFiles = stats.totalLocalFiles;
|
|
||||||
const importedCount = stats.alreadyImported;
|
|
||||||
const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0;
|
|
||||||
|
|
||||||
const showProgressBar = socketImport !== null;
|
|
||||||
const socketProgressPct =
|
|
||||||
socketImport && socketImport.total > 0
|
|
||||||
? Math.round((socketImport.completed / socketImport.total) * 100)
|
|
||||||
: 0;
|
|
||||||
const showFailedCard = hasSessionStats && failedCount > 0;
|
|
||||||
const showMissingCard = missingCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Error Message */}
|
|
||||||
{importError && (
|
|
||||||
<div className="rounded-lg border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20 p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5">
|
|
||||||
<i className="h-6 w-6 icon-[solar--danger-circle-bold]"></i>
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-semibold text-red-800 dark:text-red-300">
|
|
||||||
Import Error
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
|
||||||
{importError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setImportError(null)}
|
|
||||||
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
|
||||||
>
|
|
||||||
<i className="h-5 w-5 icon-[solar--close-circle-bold]"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File detected toast */}
|
|
||||||
{detectedFile && (
|
|
||||||
<div className="rounded-lg border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 flex items-center gap-3">
|
|
||||||
<i className="h-5 w-5 text-blue-600 dark:text-blue-400 icon-[solar--document-add-bold-duotone] shrink-0"></i>
|
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono truncate">
|
|
||||||
New file detected: {detectedFile}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Start Import button — only when idle with new files */}
|
|
||||||
{hasNewFiles && !importSession.isActive && (
|
|
||||||
<button
|
|
||||||
onClick={handleStartImport}
|
|
||||||
disabled={isStartingImport}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-green-500 hover:bg-green-600 disabled:bg-gray-400 px-6 py-3 text-white font-medium transition-colors disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<i className="h-6 w-6 icon-[solar--file-left-bold-duotone]"></i>
|
|
||||||
<span>{isStartingImport ? "Starting Import..." : buttonText}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress bar — shown while importing and once complete */}
|
|
||||||
{showProgressBar && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{socketImport!.active
|
|
||||||
? `Importing ${socketImport!.completed} / ${socketImport!.total}`
|
|
||||||
: `${socketImport!.completed} / ${socketImport!.total} imported`}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{socketProgressPct}% complete
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-300 relative"
|
|
||||||
style={{ width: `${socketProgressPct}%` }}
|
|
||||||
>
|
|
||||||
{socketImport!.active && (
|
|
||||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
||||||
{/* Total files */}
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-6 text-center"
|
|
||||||
style={{ backgroundColor: "#6b7280" }}
|
|
||||||
>
|
|
||||||
<div className="text-4xl font-bold text-white mb-2">{totalFiles}</div>
|
|
||||||
<div className="text-sm text-gray-200 font-medium">in import folder</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Imported */}
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-6 text-center"
|
|
||||||
style={{ backgroundColor: "#d8dab2" }}
|
|
||||||
>
|
|
||||||
<div className="text-4xl font-bold text-gray-800 mb-2">
|
|
||||||
{importedCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-700 font-medium">
|
|
||||||
{importSession.isActive ? "imported so far" : "imported in database"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Failed — only shown after a session with failures */}
|
|
||||||
{showFailedCard && (
|
|
||||||
<div className="rounded-lg p-6 text-center bg-red-500">
|
|
||||||
<div className="text-4xl font-bold text-white mb-2">
|
|
||||||
{failedCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-red-100 font-medium">failed</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Missing files — shown when watcher detects moved/deleted files */}
|
|
||||||
{showMissingCard && (
|
|
||||||
<div className="rounded-lg p-6 text-center bg-card-missing">
|
|
||||||
<div className="text-4xl font-bold text-slate-700 mb-2">
|
|
||||||
{missingCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-800 font-medium">missing</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Missing files detail panel */}
|
|
||||||
{showMissingCard && (
|
|
||||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<i className="h-6 w-6 text-amber-600 dark:text-amber-400 mt-0.5 icon-[solar--danger-triangle-bold] shrink-0"></i>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
|
||||||
{missingCount} {missingCount === 1 ? "file" : "files"} missing
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
|
||||||
These files were previously imported but can no longer be found
|
|
||||||
on disk. Move them back to restore access.
|
|
||||||
</p>
|
|
||||||
{missingDocs.length > 0 && (
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{missingDocs.map((comic, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className="text-xs text-amber-700 dark:text-amber-400 truncate"
|
|
||||||
>
|
|
||||||
{getMissingComicLabel(comic)} is missing
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{missingCount > 3 && (
|
|
||||||
<li className="text-xs text-amber-600 dark:text-amber-500">
|
|
||||||
and {missingCount - 3} more.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
to="/library?filter=missingFiles"
|
|
||||||
className="inline-flex items-center gap-1.5 mt-3 text-xs font-medium text-amber-800 dark:text-amber-300 underline underline-offset-2 hover:text-amber-600"
|
|
||||||
>
|
|
||||||
|
|
||||||
<span className="underline">
|
|
||||||
<i className="icon-[solar--file-corrupted-outline] w-4 h-4 px-3" />
|
|
||||||
View Missing Files In Library
|
|
||||||
<i className="icon-[solar--arrow-right-up-outline] w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RealTimeImportStats;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, ReactElement, useState } from "react";
|
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import PropTypes from "prop-types";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
@@ -11,150 +12,101 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, fromUnixTime, parseISO } from "date-fns";
|
||||||
import { useGetWantedComicsQuery } from "../../graphql/generated";
|
|
||||||
|
|
||||||
type FilterOption = "all" | "missingFiles";
|
|
||||||
|
|
||||||
interface SearchQuery {
|
|
||||||
query: Record<string, any>;
|
|
||||||
pagination: { size: number; from: number };
|
|
||||||
type: string;
|
|
||||||
trigger: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
|
||||||
{ value: "all", label: "All Comics" },
|
|
||||||
{ value: "missingFiles", label: "Missing Files" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library page component. Displays a paginated, searchable table of all comics
|
* Component that tabulates the contents of the user's ThreeTwo Library.
|
||||||
* in the collection, with an optional filter for comics with missing raw files.
|
*
|
||||||
|
* @component
|
||||||
|
* @example
|
||||||
|
* <Library />
|
||||||
*/
|
*/
|
||||||
export const Library = (): ReactElement => {
|
export const Library = (): ReactElement => {
|
||||||
const [searchParams] = useSearchParams();
|
// Default page state
|
||||||
const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all";
|
// offset: 0
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
const [activeFilter, setActiveFilter] = useState<FilterOption>(initialFilter);
|
const [searchQuery, setSearchQuery] = useState({
|
||||||
const [searchQuery, setSearchQuery] = useState<SearchQuery>({
|
|
||||||
query: {},
|
query: {},
|
||||||
pagination: { size: 25, from: 0 },
|
pagination: {
|
||||||
|
size: 25,
|
||||||
|
from: offset,
|
||||||
|
},
|
||||||
type: "all",
|
type: "all",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
/** Fetches a page of issues from the search API. */
|
/**
|
||||||
const fetchIssues = async (q: SearchQuery) => {
|
* Method that queries the Elasticsearch index "comics" for issues specified by the query
|
||||||
const { pagination, query, type } = q;
|
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
|
||||||
|
*/
|
||||||
|
const fetchIssues = async (searchQuery) => {
|
||||||
|
const { pagination, query, type } = searchQuery;
|
||||||
return await axios({
|
return await axios({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "http://localhost:3000/api/search/searchIssue",
|
url: "http://localhost:3000/api/search/searchIssue",
|
||||||
data: { query, pagination, type },
|
data: {
|
||||||
|
query,
|
||||||
|
pagination,
|
||||||
|
type,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, isPlaceholderData } = useQuery({
|
const searchIssues = (e) => {
|
||||||
queryKey: ["comics", searchQuery],
|
|
||||||
queryFn: () => fetchIssues(searchQuery),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
enabled: activeFilter === "all",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery(
|
|
||||||
{
|
|
||||||
paginationOptions: { limit: 25, page: 1 },
|
|
||||||
predicate: { "importStatus.isRawFileMissing": true },
|
|
||||||
},
|
|
||||||
{ enabled: activeFilter === "missingFiles" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: missingIdsData } = useGetWantedComicsQuery(
|
|
||||||
{
|
|
||||||
paginationOptions: { limit: 1000, page: 1 },
|
|
||||||
predicate: { "importStatus.isRawFileMissing": true },
|
|
||||||
},
|
|
||||||
{ enabled: activeFilter === "all" },
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */
|
|
||||||
const missingIdSet = useMemo(
|
|
||||||
() => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)),
|
|
||||||
[missingIdsData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchResults = data?.data;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`);
|
|
||||||
const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`);
|
|
||||||
|
|
||||||
/** Triggers a search by volume name and resets pagination. */
|
|
||||||
const searchIssues = (e: any) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
setSearchQuery({
|
setSearchQuery({
|
||||||
query: { volumeName: e.search },
|
query: {
|
||||||
pagination: { size: 15, from: 0 },
|
volumeName: e.search,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
type: "volumeName",
|
type: "volumeName",
|
||||||
trigger: "libraryPage",
|
trigger: "libraryPage",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Advances to the next page of results. */
|
const { data, isLoading, isError, isPlaceholderData } = useQuery({
|
||||||
const nextPage = (pageIndex: number, pageSize: number) => {
|
queryKey: ["comics", offset, searchQuery],
|
||||||
if (!isPlaceholderData) {
|
queryFn: () => fetchIssues(searchQuery),
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
placeholderData: keepPreviousData,
|
||||||
setSearchQuery({
|
});
|
||||||
query: {},
|
|
||||||
pagination: { size: 15, from: pageSize * pageIndex + 1 },
|
const searchResults = data?.data;
|
||||||
type: "all",
|
// Programmatically navigate to comic detail
|
||||||
trigger: "libraryPage",
|
const navigate = useNavigate();
|
||||||
});
|
const navigateToComicDetail = (row) => {
|
||||||
}
|
navigate(`/comic/details/${row.original._id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Goes back to the previous page of results. */
|
const ComicInfoXML = (value) => {
|
||||||
const previousPage = (pageIndex: number, pageSize: number) => {
|
return value.data ? (
|
||||||
let from = 0;
|
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
|
||||||
if (pageIndex === 2) {
|
{/* Series Name */}
|
||||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
<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">
|
||||||
} else {
|
<span className="pr-1 pt-1">
|
||||||
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
|
||||||
setSearchQuery({
|
|
||||||
query: {},
|
|
||||||
pagination: { size: 15, from },
|
|
||||||
type: "all",
|
|
||||||
trigger: "libraryPage",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ComicInfoXML = (value: any) =>
|
|
||||||
value.data ? (
|
|
||||||
<dl className="flex flex-col text-xs sm:text-md p-2 sm:p-3 ml-0 sm:ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-full sm:w-max max-w-full">
|
|
||||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs font-medium px-1.5 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 max-w-full overflow-hidden">
|
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
|
||||||
<i className="icon-[solar--bookmark-square-minimalistic-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900 truncate">
|
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||||
{ellipsize(value.data.series[0], 25)}
|
{ellipsize(value.data.series[0], 45)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row flex-wrap mt-1 sm:mt-2 gap-1 sm:gap-2">
|
<div className="flex flex-row mt-2 gap-2">
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
{/* Pages */}
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
<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">
|
||||||
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-3.5 h-3.5 sm:w-5 sm:h-5"></i>
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--notebook-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||||
Pages: {value.data.pagecount[0]}
|
Pages: {value.data.pagecount[0]}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-1 sm:px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
{/* Issue number */}
|
||||||
<span className="pr-0.5 sm:pr-1 pt-1">
|
<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">
|
||||||
<i className="icon-[solar--hashtag-outline] w-3 h-3 sm:w-3.5 sm:h-3.5"></i>
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-900 dark:text-slate-900">
|
<span className="text-slate-900 dark:text-slate-900">
|
||||||
{!isNil(value.data.number) && (
|
{!isNil(value.data.number) && (
|
||||||
@@ -165,62 +117,30 @@ export const Library = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
) : null;
|
) : null;
|
||||||
|
};
|
||||||
const missingFilesColumns = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
header: "Missing Files",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Status",
|
|
||||||
id: "missingStatus",
|
|
||||||
cell: () => (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 px-2 py-3 min-w-[80px]">
|
|
||||||
<i className="icon-[solar--file-corrupted-outline] w-8 h-8 text-red-500"></i>
|
|
||||||
<span className="inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/20">
|
|
||||||
MISSING
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Comic",
|
|
||||||
id: "missingComic",
|
|
||||||
minWidth: 250,
|
|
||||||
accessorFn: (row: any) => row,
|
|
||||||
cell: (info: any) => <MetadataPanel data={info.getValue()} />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: "Comic Metadata",
|
header: "Comic Metadata",
|
||||||
|
footer: 1,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "File Details",
|
header: "File Details",
|
||||||
id: "fileDetails",
|
id: "fileDetails",
|
||||||
minWidth: 250,
|
minWidth: 400,
|
||||||
accessorKey: "_source",
|
accessorKey: "_source",
|
||||||
cell: (info: any) => {
|
cell: (info) => {
|
||||||
const source = info.getValue();
|
return <MetadataPanel data={info.getValue()} />;
|
||||||
return (
|
|
||||||
<MetadataPanel
|
|
||||||
data={source}
|
|
||||||
isMissing={missingIdSet.has(info.row.original._id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "ComicInfo.xml",
|
header: "ComicInfo.xml",
|
||||||
accessorKey: "_source.sourcedMetadata.comicInfo",
|
accessorKey: "_source.sourcedMetadata.comicInfo",
|
||||||
cell: (info: any) =>
|
cell: (info) =>
|
||||||
!isEmpty(info.getValue()) ? <ComicInfoXML data={info.getValue()} /> : null,
|
!isEmpty(info.getValue()) ? (
|
||||||
|
<ComicInfoXML data={info.getValue()} />
|
||||||
|
) : null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -230,130 +150,168 @@ export const Library = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Date of Import",
|
header: "Date of Import",
|
||||||
accessorKey: "_source.createdAt",
|
accessorKey: "_source.createdAt",
|
||||||
cell: (info: any) =>
|
cell: (info) => {
|
||||||
!isNil(info.getValue()) ? (
|
return !isNil(info.getValue()) ? (
|
||||||
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
|
<span className="inline-flex items-center bg-slate-300 dark:bg-slate-500 text-xs font-medium text-slate-700 dark:text-slate-200 px-3 py-1 rounded-md shadow-sm whitespace-nowrap ml-3 my-3">
|
||||||
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")}</p>
|
<i className="icon-[solar--file-download-bold] w-4 h-4 mr-2 opacity-70" />
|
||||||
{format(parseISO(info.getValue()), "h aaaa")}
|
{format(parseISO(info.getValue()), "dd MMM yyyy, h:mm a")}
|
||||||
</div>
|
</span>
|
||||||
) : null,
|
) : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Downloads",
|
header: "Downloads",
|
||||||
accessorKey: "_source.acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (info: any) => (
|
cell: (info) => (
|
||||||
<div className="flex flex-col gap-2 ml-3 my-3">
|
<div className="flex flex-col gap-2 ml-3 my-3">
|
||||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
{/* DC++ Downloads */}
|
||||||
<span className="pr-1 pt-1">
|
{info.getValue().directconnect?.downloads?.length > 0 ? (
|
||||||
<i className="icon-[solar--folder-path-connect-bold-duotone] w-5 h-5"></i>
|
<span className="inline-flex 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">
|
||||||
|
<i className="icon-[solar--folder-path-connect-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
|
<span>
|
||||||
|
DC++: {info.getValue().directconnect.downloads.length}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
DC++: {info.getValue().directconnect.downloads.length}
|
) : null}
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center w-fit bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400 whitespace-nowrap">
|
{/* Torrent Downloads */}
|
||||||
<span className="pr-1 pt-1">
|
{info.getValue().torrent.length > 0 ? (
|
||||||
<i className="icon-[solar--magnet-bold-duotone] w-5 h-5"></i>
|
<span className="inline-flex items-center whitespace-nowrap 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">
|
||||||
|
<i className="icon-[solar--magnet-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
Torrent: {info.getValue().torrent.length}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
Torrent: {info.getValue().torrent.length}
|
) : null}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[missingIdSet],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const FilterDropdown = () => (
|
/**
|
||||||
<div className="relative">
|
* Pagination control that fetches the next x (pageSize) items
|
||||||
<select
|
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||||
value={activeFilter}
|
* @param {number} pageIndex
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setActiveFilter(e.target.value as FilterOption)}
|
* @param {number} pageSize
|
||||||
className="appearance-none h-full rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 pl-3 pr-8 py-1.5 text-sm text-gray-700 dark:text-slate-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
* @returns void
|
||||||
>
|
*
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
**/
|
||||||
<option key={opt.value} value={opt.value}>
|
const nextPage = (pageIndex: number, pageSize: number) => {
|
||||||
{opt.label}
|
if (!isPlaceholderData) {
|
||||||
</option>
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
))}
|
setSearchQuery({
|
||||||
</select>
|
query: {},
|
||||||
<i className="icon-[solar--alt-arrow-down-bold] absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 dark:text-slate-400 pointer-events-none"></i>
|
pagination: {
|
||||||
</div>
|
size: 15,
|
||||||
);
|
from: pageSize * pageIndex + 1,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// setOffset(pageSize * pageIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isMissingFilter = activeFilter === "missingFiles";
|
/**
|
||||||
|
* Pagination control that fetches the previous x (pageSize) items
|
||||||
|
* based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index
|
||||||
|
* @param {number} pageIndex
|
||||||
|
* @param {number} pageSize
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
|
const previousPage = (pageIndex: number, pageSize: number) => {
|
||||||
|
let from = 0;
|
||||||
|
if (pageIndex === 2) {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
|
||||||
|
} else {
|
||||||
|
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["comics"] });
|
||||||
|
setSearchQuery({
|
||||||
|
query: {},
|
||||||
|
pagination: {
|
||||||
|
size: 15,
|
||||||
|
from,
|
||||||
|
},
|
||||||
|
type: "all",
|
||||||
|
trigger: "libraryPage",
|
||||||
|
});
|
||||||
|
// setOffset(from);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ImportStatus.propTypes = {
|
||||||
|
// value: PropTypes.bool.isRequired,
|
||||||
|
// };
|
||||||
return (
|
return (
|
||||||
<section>
|
<div>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<section>
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<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="text-center sm:text-left">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
<div className="text-center sm:text-left">
|
||||||
Library
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
</h1>
|
Library
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
</h1>
|
||||||
Browse your comic book collection.
|
|
||||||
</p>
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse your comic book collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
{!isUndefined(searchResults?.hits) ? (
|
||||||
|
<div>
|
||||||
{isMissingFilter ? (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
|
||||||
{isMissingLoading ? (
|
|
||||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<T2Table
|
|
||||||
totalPages={missingFilesData?.getComicBooks?.totalDocs ?? 0}
|
|
||||||
columns={missingFilesColumns}
|
|
||||||
sourceData={missingFilesData?.getComicBooks?.docs ?? []}
|
|
||||||
rowClickHandler={navigateToMissingComicDetail}
|
|
||||||
getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
|
|
||||||
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
|
|
||||||
>
|
|
||||||
<FilterDropdown />
|
|
||||||
</T2Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : !isUndefined(searchResults?.hits) ? (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
|
||||||
<T2Table
|
|
||||||
totalPages={searchResults.hits.total.value}
|
|
||||||
columns={columns}
|
|
||||||
sourceData={searchResults?.hits.hits}
|
|
||||||
rowClickHandler={navigateToComicDetail}
|
|
||||||
getRowClassName={(row) =>
|
|
||||||
missingIdSet.has(row.original._id)
|
|
||||||
? "bg-card-missing/40 hover:bg-card-missing/20"
|
|
||||||
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
|
|
||||||
}
|
|
||||||
paginationHandlers={{ nextPage, previousPage }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FilterDropdown />
|
|
||||||
<SearchBar searchHandler={(e: any) => searchIssues(e)} />
|
|
||||||
</div>
|
|
||||||
</T2Table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto max-w-screen-xl mt-5">
|
|
||||||
<article
|
|
||||||
role="alert"
|
|
||||||
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<T2Table
|
||||||
No comics were found in the library, Elasticsearch reports no indices. Try
|
totalPages={searchResults.hits.total.value}
|
||||||
importing a few comics into the library and come back.
|
columns={columns}
|
||||||
</p>
|
sourceData={searchResults?.hits.hits}
|
||||||
|
rowClickHandler={navigateToComicDetail}
|
||||||
|
paginationHandlers={{
|
||||||
|
nextPage,
|
||||||
|
previousPage,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchBar searchHandler={(e) => searchIssues(e)} />
|
||||||
|
</T2Table>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
<FilterDropdown />
|
) : (
|
||||||
</div>
|
<div className="mx-auto max-w-screen-xl mt-5">
|
||||||
)}
|
<article
|
||||||
</section>
|
role="alert"
|
||||||
|
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
No comics were found in the library, Elasticsearch reports no
|
||||||
|
indices. Try importing a few comics into the library and come
|
||||||
|
back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div className="block max-w-md p-6 bg-white border border-gray-200 my-3 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
|
<pre className="text-sm font-hasklig text-slate-700 dark:text-slate-700">
|
||||||
|
{!isUndefined(searchResults?.data?.meta?.body) ? (
|
||||||
|
<p>
|
||||||
|
{JSON.stringify(
|
||||||
|
searchResults?.data.meta.body.error.root_cause,
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,11 +72,8 @@ export const LibraryGrid = (libraryGridProps: ILibraryGridProps) => {
|
|||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div className="content is-flex is-flex-direction-row">
|
||||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
{!isEmpty(sourcedMetadata.comicvine) && (
|
||||||
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
<span className="icon cv-icon is-small">
|
||||||
<img
|
<img src="/src/client/assets/img/cvlogo.svg" />
|
||||||
src="/src/client/assets/img/cvlogo.svg"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isNil(rawFileDetails) && (
|
{isNil(rawFileDetails) && (
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const PullList = (): ReactElement => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<section className="container mx-auto px-4 sm:px-6 lg:px-8">
|
<section className="container">
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<div className="header-area">
|
<div className="header-area">
|
||||||
<h1 className="title">Weekly Pull List</h1>
|
<h1 className="title">Weekly Pull List</h1>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import "../../shared/utils/i18n.util";
|
import "../../shared/utils/i18n.util";
|
||||||
import PopoverButton from "../shared/PopoverButton";
|
import PopoverButton from "../shared/PopoverButton";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
COMICVINE_SERVICE_URI,
|
COMICVINE_SERVICE_URI,
|
||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
@@ -20,7 +20,6 @@ import axios from "axios";
|
|||||||
interface ISearchProps {}
|
interface ISearchProps {}
|
||||||
|
|
||||||
export const Search = ({}: ISearchProps): ReactElement => {
|
export const Search = ({}: ISearchProps): ReactElement => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const formData = {
|
const formData = {
|
||||||
search: "",
|
search: "",
|
||||||
};
|
};
|
||||||
@@ -78,6 +77,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
coverDate: cover_date,
|
coverDate: cover_date,
|
||||||
issueNumber: issue_number,
|
issueNumber: issue_number,
|
||||||
});
|
});
|
||||||
|
console.log(issues);
|
||||||
// Get volume metadata from CV
|
// Get volume metadata from CV
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
||||||
@@ -110,6 +110,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
console.log("Invalid resource type.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Add to wanted list
|
// Add to wanted list
|
||||||
@@ -137,10 +138,6 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate and refetch wanted comics queries
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToLibrary = (sourceName: string, comicData) =>
|
const addToLibrary = (sourceName: string, comicData) =>
|
||||||
@@ -163,7 +160,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<div className="px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
@@ -269,55 +266,80 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<div className="mx-auto w-full sm:w-[90vw] md:w-[80vw] lg:w-[70vw] max-w-6xl px-4 py-6">
|
||||||
{comicVineSearchResults.data.results.map((result) => {
|
{comicVineSearchResults.data.results.map((result) => {
|
||||||
return result.resource_type === "issue" ? (
|
return result.resource_type === "issue" ? (
|
||||||
<div
|
<div
|
||||||
key={result.id}
|
key={result.id}
|
||||||
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
|
className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
{/* IMAGE */}
|
||||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
<div className="flex-shrink-0">
|
||||||
<Card
|
<Card
|
||||||
key={result.id}
|
orientation="cover-only"
|
||||||
orientation={"cover-only"}
|
imageUrl={result.image.small_url}
|
||||||
imageUrl={result.image.small_url}
|
hasDetails={false}
|
||||||
hasDetails={false}
|
cardContainerStyle={{ width: "120px", maxWidth: "150px" }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT-SIDE CONTENT */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* TITLE */}
|
||||||
|
<div className="text-base font-medium text-slate-800 dark:text-white tracking-tight truncate">
|
||||||
|
{result.volume?.name || <span>No Name</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-3/4">
|
|
||||||
<div className="text-xl">
|
{/* SUBMETA */}
|
||||||
{!isEmpty(result.volume.name) ? (
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
result.volume.name
|
{/* Cover Date Token */}
|
||||||
) : (
|
|
||||||
<span className="is-size-3">No Name</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{result.cover_date && (
|
{result.cover_date && (
|
||||||
<p>
|
<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="tag is-light">Cover date</span>
|
<span className="pr-1 pt-1">
|
||||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4"></i>
|
||||||
</p>
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-900">
|
||||||
|
{dayjs(result.cover_date).format("MMM YYYY")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="tag is-warning">{result.id}</p>
|
{/* ID Token */}
|
||||||
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4"></i>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-900">
|
||||||
|
{result.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a href={result.api_detail_url}>
|
{/* LINK */}
|
||||||
{result.api_detail_url}
|
<a
|
||||||
</a>
|
href={result.api_detail_url}
|
||||||
<p className="text-sm">
|
className="text-xs text-blue-500 underline mt-1 inline-block break-all"
|
||||||
|
>
|
||||||
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
{result.description && (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-200 mt-2 line-clamp-3">
|
||||||
{ellipsize(
|
{ellipsize(
|
||||||
convert(result.description, {
|
convert(result.description ?? "", {
|
||||||
baseElements: {
|
baseElements: { selectors: ["p", "div"] },
|
||||||
selectors: ["p", "div"],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
320,
|
300,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
)}
|
||||||
|
|
||||||
|
{/* CTA BUTTON */}
|
||||||
|
{result.volume.name && (
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
content={`This will add ${result.volume.name} to your wanted list.`}
|
content={`This will add ${result?.volume?.name} to your wanted list.`}
|
||||||
clickHandler={() =>
|
clickHandler={() =>
|
||||||
addToWantedList({
|
addToWantedList({
|
||||||
source: "comicvine",
|
source: "comicvine",
|
||||||
@@ -328,114 +350,109 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
result.resource_type === "volume" && (
|
result.resource_type === "volume" && (
|
||||||
<div
|
<div
|
||||||
key={result.id}
|
key={result.id}
|
||||||
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
|
className="relative flex gap-4 py-6 px-3 border-b border-slate-200 dark:border-slate-700 hover:bg-slate-100/50 dark:hover:bg-slate-800/30 transition-colors duration-150 group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
{/* LEFT COLUMN: COVER */}
|
||||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
<Card
|
||||||
<Card
|
orientation="cover-only"
|
||||||
key={result.id}
|
imageUrl={result.image.small_url}
|
||||||
orientation={"cover-only"}
|
hasDetails={false}
|
||||||
imageUrl={result.image.small_url}
|
cardContainerStyle={{
|
||||||
hasDetails={false}
|
width: "120px",
|
||||||
/>
|
maxWidth: "150px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* TITLE */}
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{result.name || <span>No Name</span>}
|
||||||
|
{result.start_year && <> ({result.start_year})</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-3/4">
|
|
||||||
<div className="text-xl">
|
|
||||||
{!isEmpty(result.name) ? (
|
|
||||||
result.name
|
|
||||||
) : (
|
|
||||||
<span className="text-xl">No Name</span>
|
|
||||||
)}
|
|
||||||
{result.start_year && <> ({result.start_year})</>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
{/* TOKENS */}
|
||||||
{/* issue count */}
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
{result.count_of_issues && (
|
{/* ISSUE COUNT */}
|
||||||
<div className="my-2">
|
{result.count_of_issues && (
|
||||||
<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-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
<span className="pr-1 pt-1">
|
<span className="pr-1 pt-1">
|
||||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("issueWithCount", {
|
||||||
|
count: result.count_of_issues,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
{/* FORMAT DETECTED */}
|
||||||
{t("issueWithCount", {
|
{result.description &&
|
||||||
count: result.count_of_issues,
|
!isEmpty(detectIssueTypes(result.description)) && (
|
||||||
})}
|
<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>
|
<span className="pr-1 pt-1">
|
||||||
|
<i className="icon-[solar--book-2-line-duotone] w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span>
|
||||||
|
{
|
||||||
|
detectIssueTypes(result.description)
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* type: TPB, one-shot, graphic novel etc. */}
|
|
||||||
{!isNil(result.description) &&
|
|
||||||
!isUndefined(result.description) && (
|
|
||||||
<>
|
|
||||||
{!isEmpty(
|
|
||||||
detectIssueTypes(result.description),
|
|
||||||
) && (
|
|
||||||
<div className="my-2">
|
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
|
||||||
<span className="pr-1 pt-1">
|
|
||||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
{/* ID */}
|
||||||
{
|
<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">
|
||||||
detectIssueTypes(result.description)
|
<span className="pr-1 pt-1">
|
||||||
.displayName
|
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4" />
|
||||||
}
|
</span>
|
||||||
</span>
|
<span>{result.id}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="tag is-warning">{result.id}</span>
|
{/* LINK */}
|
||||||
<p>
|
<a
|
||||||
<a href={result.api_detail_url}>
|
href={result.api_detail_url}
|
||||||
{result.api_detail_url}
|
className="text-sm text-blue-500 underline mt-2 break-all"
|
||||||
</a>
|
>
|
||||||
</p>
|
{result.api_detail_url}
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* description */}
|
{/* DESCRIPTION */}
|
||||||
<p className="text-sm">
|
{result.description && (
|
||||||
|
<p className="text-sm mt-2 text-slate-700 dark:text-slate-200 break-words line-clamp-3">
|
||||||
{ellipsize(
|
{ellipsize(
|
||||||
convert(result.description, {
|
convert(result.description, {
|
||||||
baseElements: {
|
baseElements: { selectors: ["p", "div"] },
|
||||||
selectors: ["p", "div"],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
320,
|
320,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
)}
|
||||||
|
{result.name ? (
|
||||||
|
<div className="mt-4 justify-self-end">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
content={`Adding this volume will add ${t(
|
content={`This will add ${result.count_of_issues} issues your wanted list.`}
|
||||||
"issueWithCount",
|
|
||||||
{
|
|
||||||
count: result.count_of_issues,
|
|
||||||
},
|
|
||||||
)} to your wanted list.`}
|
|
||||||
clickHandler={() =>
|
clickHandler={() =>
|
||||||
addToWantedList({
|
addToWantedList({
|
||||||
source: "comicvine",
|
source: "comicvine",
|
||||||
comicObject: result,
|
comicObject: result,
|
||||||
markEntireVolumeWanted: true,
|
markEntireVolumeWanted: false,
|
||||||
resourceType: "volume",
|
resourceType: "issue",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const AirDCPPSettingsForm = () => {
|
|||||||
// Handle setting update and subsequent AirDC++ initialization
|
// Handle setting update and subsequent AirDC++ initialization
|
||||||
const { mutate } = useMutation({
|
const { mutate } = useMutation({
|
||||||
mutationFn: (values) => {
|
mutationFn: (values) => {
|
||||||
|
console.log(values);
|
||||||
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
settingsPayload: values,
|
settingsPayload: values,
|
||||||
settingsKey: "directConnect",
|
settingsKey: "directConnect",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const DockerVars = (): React.ReactElement => {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
|
console.log("Docker Vars: ", environmentVariables);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
|
<h2 className="text-xl font-semibold">Docker Environment Variables</h2>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const ProwlarrSettingsForm = (props) => {
|
|||||||
},
|
},
|
||||||
queryKey: ["prowlarrConnectionResult"],
|
queryKey: ["prowlarrConnectionResult"],
|
||||||
});
|
});
|
||||||
|
console.log(data);
|
||||||
const submitHandler = () => {};
|
const submitHandler = () => {};
|
||||||
const initialData = {};
|
const initialData = {};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
@@ -280,7 +280,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 mt-4">
|
<div className="container mx-auto mt-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
{/* Volume cover */}
|
{/* Volume cover */}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const Volumes = (props): ReactElement => {
|
|||||||
}),
|
}),
|
||||||
queryKey: ["volumes"],
|
queryKey: ["volumes"],
|
||||||
});
|
});
|
||||||
|
console.log(volumes);
|
||||||
const columnData = useMemo(
|
const columnData = useMemo(
|
||||||
(): any => [
|
(): any => [
|
||||||
{
|
{
|
||||||
@@ -45,6 +46,7 @@ export const Volumes = (props): ReactElement => {
|
|||||||
const {
|
const {
|
||||||
_source: { sourcedMetadata },
|
_source: { sourcedMetadata },
|
||||||
} = comicObject;
|
} = comicObject;
|
||||||
|
console.log("jaggu", row.getValue());
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-3 mt-5">
|
<div className="flex flex-row gap-3 mt-5">
|
||||||
<Link to={`/volume/details/${comicObject._id}`}>
|
<Link to={`/volume/details/${comicObject._id}`}>
|
||||||
@@ -141,7 +143,7 @@ export const Volumes = (props): ReactElement => {
|
|||||||
<div>
|
<div>
|
||||||
<section className="">
|
<section className="">
|
||||||
<header className="bg-slate-200 dark:bg-slate-500">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
<div className="mx-auto max-w-screen-xl px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
|
||||||
<div className="sm:flex sm:items-center sm:justify-between">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
||||||
@@ -156,7 +158,7 @@ export const Volumes = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{isSuccess ? (
|
{isSuccess ? (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<div>
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<T2Table
|
<T2Table
|
||||||
sourceData={volumes?.data.hits.hits}
|
sourceData={volumes?.data.hits.hits}
|
||||||
|
|||||||
@@ -1,37 +1,87 @@
|
|||||||
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
import React from "react";
|
||||||
import SearchBar from "../Library/SearchBar";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { gql, GraphQLClient } from "graphql-request";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
|
||||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
|
||||||
|
|
||||||
export const WantedComics = (props): ReactElement => {
|
/**
|
||||||
const {
|
* GraphQL client for interfacing with Moleculer Apollo server.
|
||||||
data: wantedComics,
|
*/
|
||||||
isSuccess,
|
const client = new GraphQLClient("http://localhost:3000/graphql");
|
||||||
isFetched,
|
|
||||||
isError,
|
|
||||||
isLoading,
|
|
||||||
} = useQuery({
|
|
||||||
queryFn: async () =>
|
|
||||||
await axios({
|
|
||||||
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
query: {},
|
|
||||||
|
|
||||||
pagination: {
|
/**
|
||||||
size: 25,
|
* GraphQL query to fetch wanted comics.
|
||||||
from: 0,
|
*/
|
||||||
},
|
const WANTED_COMICS_QUERY = gql`
|
||||||
type: "wanted",
|
query {
|
||||||
trigger: "wantedComicsPage",
|
wantedComics(limit: 25, offset: 0) {
|
||||||
},
|
total
|
||||||
}),
|
comics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of an individual comic returned by the backend.
|
||||||
|
*/
|
||||||
|
type Comic = {
|
||||||
|
_id: string;
|
||||||
|
sourcedMetadata?: {
|
||||||
|
comicvine?: {
|
||||||
|
name?: string;
|
||||||
|
start_year?: string;
|
||||||
|
publisher?: {
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
acquisition?: {
|
||||||
|
directconnect?: {
|
||||||
|
downloads?: Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the GraphQL response returned for wanted comics.
|
||||||
|
*/
|
||||||
|
type WantedComicsResponse = {
|
||||||
|
wantedComics: {
|
||||||
|
total: number;
|
||||||
|
comics: Comic[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React component rendering the "Wanted Comics" table using T2Table.
|
||||||
|
* Fetches data from GraphQL backend via graphql-request + TanStack Query.
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @returns {JSX.Element} React component
|
||||||
|
*/
|
||||||
|
const WantedComics = (): JSX.Element => {
|
||||||
|
const { data, isLoading, isError, isSuccess, error } = useQuery<
|
||||||
|
WantedComicsResponse["wantedComics"]
|
||||||
|
>({
|
||||||
queryKey: ["wantedComics"],
|
queryKey: ["wantedComics"],
|
||||||
enabled: true,
|
queryFn: async () => {
|
||||||
|
const res = await client.request<WantedComicsResponse>(
|
||||||
|
WANTED_COMICS_QUERY,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res?.wantedComics?.comics) {
|
||||||
|
throw new Error("No comics returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.wantedComics;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const columnData = [
|
const columnData = [
|
||||||
{
|
{
|
||||||
header: "Comic Information",
|
header: "Comic Information",
|
||||||
@@ -40,10 +90,11 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
header: "Details",
|
header: "Details",
|
||||||
id: "comicDetails",
|
id: "comicDetails",
|
||||||
minWidth: 350,
|
minWidth: 350,
|
||||||
accessorFn: (data) => data,
|
accessorFn: (data: Comic) => data,
|
||||||
cell: (value) => {
|
cell: (value: any) => {
|
||||||
const row = value.getValue()._source;
|
const row = value.getValue();
|
||||||
return row && <MetadataPanel data={row} />;
|
console.log("Comic row data:", row);
|
||||||
|
return row ? <MetadataPanel data={row} /> : null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -54,148 +105,73 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Files",
|
header: "Files",
|
||||||
align: "right",
|
align: "right",
|
||||||
accessorKey: "_source.acquisition",
|
accessorFn: (row: Comic) =>
|
||||||
cell: (props) => {
|
row?.acquisition?.directconnect?.downloads || [],
|
||||||
const {
|
cell: (props: any) => {
|
||||||
directconnect: { downloads },
|
const downloads = props.getValue();
|
||||||
} = props.getValue();
|
return downloads?.length > 0 ? (
|
||||||
return (
|
<span className="tag is-warning">{downloads.length}</span>
|
||||||
<div
|
) : null;
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
// flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{downloads.length > 0 ? (
|
|
||||||
<span className="tag is-warning">{downloads.length}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Download Details",
|
header: "Download Details",
|
||||||
id: "downloadDetails",
|
id: "downloadDetails",
|
||||||
accessorKey: "_source.acquisition",
|
accessorFn: (row: Comic) =>
|
||||||
cell: (data) => (
|
row?.acquisition?.directconnect?.downloads || [],
|
||||||
|
cell: (data: any) => (
|
||||||
<ol>
|
<ol>
|
||||||
{data.getValue().directconnect.downloads.map((download, idx) => {
|
{data.getValue()?.map((download: any, idx: number) => (
|
||||||
return (
|
<li className="is-size-7" key={idx}>
|
||||||
<li className="is-size-7" key={idx}>
|
{download.name}
|
||||||
{download.name}
|
</li>
|
||||||
</li>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
</ol>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Type",
|
|
||||||
id: "dcc",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Pagination control that fetches the next x (pageSize) items
|
|
||||||
* based on the y (pageIndex) offset from the Elasticsearch index
|
|
||||||
* @param {number} pageIndex
|
|
||||||
* @param {number} pageSize
|
|
||||||
* @returns void
|
|
||||||
*
|
|
||||||
**/
|
|
||||||
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
|
|
||||||
// dispatch(
|
|
||||||
// searchIssue(
|
|
||||||
// {
|
|
||||||
// query: {},
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// pagination: {
|
|
||||||
// size: pageSize,
|
|
||||||
// from: pageSize * pageIndex + 1,
|
|
||||||
// },
|
|
||||||
// type: "wanted",
|
|
||||||
// trigger: "wantedComicsPage",
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pagination control that fetches the previous x (pageSize) items
|
|
||||||
* based on the y (pageIndex) offset from the Elasticsearch index
|
|
||||||
* @param {number} pageIndex
|
|
||||||
* @param {number} pageSize
|
|
||||||
* @returns void
|
|
||||||
**/
|
|
||||||
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
|
|
||||||
// let from = 0;
|
|
||||||
// if (pageIndex === 2) {
|
|
||||||
// from = (pageIndex - 1) * pageSize + 2 - 17;
|
|
||||||
// } else {
|
|
||||||
// from = (pageIndex - 1) * pageSize + 2 - 16;
|
|
||||||
// }
|
|
||||||
// dispatch(
|
|
||||||
// searchIssue(
|
|
||||||
// {
|
|
||||||
// query: {},
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// pagination: {
|
|
||||||
// size: pageSize,
|
|
||||||
// from,
|
|
||||||
// },
|
|
||||||
// type: "wanted",
|
|
||||||
// trigger: "wantedComicsPage",
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<section>
|
||||||
<section className="">
|
<header className="bg-slate-200 dark:bg-slate-500">
|
||||||
<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="sm:flex sm:items-center sm:justify-between">
|
<div className="text-center sm:text-left">
|
||||||
<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 font-bold text-gray-900 dark:text-white sm:text-3xl">
|
Wanted Comics
|
||||||
Wanted Comics
|
</h1>
|
||||||
</h1>
|
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
||||||
|
Browse through comics you marked as "wanted."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
|
{isLoading && (
|
||||||
Browse through comics you marked as "wanted."
|
<div className="animate-pulse p-4 space-y-4">
|
||||||
</p>
|
{Array.from({ length: 5 }).map((_, idx) => (
|
||||||
</div>
|
<div
|
||||||
</div>
|
key={idx}
|
||||||
</div>
|
className="h-24 bg-slate-300 dark:bg-slate-600 rounded-md"
|
||||||
</header>
|
/>
|
||||||
{isSuccess && wantedComics?.data.hits?.hits ? (
|
))}
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
</div>
|
||||||
<div className="library">
|
)}
|
||||||
<T2Table
|
{isError && <div>Error fetching wanted comics. {error?.message}</div>}
|
||||||
sourceData={wantedComics?.data.hits.hits}
|
{isSuccess && data?.comics?.length > 0 ? (
|
||||||
totalPages={wantedComics?.data.hits.hits.length}
|
<T2Table
|
||||||
columns={columnData}
|
sourceData={data.comics}
|
||||||
paginationHandlers={{
|
totalPages={data.comics.length}
|
||||||
nextPage: () => {},
|
columns={columnData}
|
||||||
previousPage: () => {},
|
paginationHandlers={{}}
|
||||||
}}
|
/>
|
||||||
// rowClickHandler={navigateToComicDetail}
|
) : isSuccess ? (
|
||||||
/>
|
<div>No comics found.</div>
|
||||||
{/* pagination controls */}
|
) : null}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? <div>Loading...</div> : null}
|
|
||||||
{isError ? (
|
|
||||||
<div>An error occurred while retrieving the pull list.</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,31 +10,11 @@ interface ICardProps {
|
|||||||
children?: PropTypes.ReactNodeLike;
|
children?: PropTypes.ReactNodeLike;
|
||||||
borderColorClass?: string;
|
borderColorClass?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
|
||||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
cardContainerStyle?: React.CSSProperties;
|
cardContainerStyle?: any;
|
||||||
imageStyle?: React.CSSProperties;
|
imageStyle?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
|
||||||
case "missing":
|
|
||||||
return "bg-card-missing";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCard = (props: ICardProps): ReactElement => {
|
const renderCard = (props: ICardProps): ReactElement => {
|
||||||
switch (props.orientation) {
|
switch (props.orientation) {
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
@@ -103,23 +83,12 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
|
|
||||||
case "vertical-2":
|
case "vertical-2":
|
||||||
return (
|
return (
|
||||||
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
|
<div className="block rounded-md max-w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
|
||||||
<div className="relative">
|
<img
|
||||||
{props.imageUrl ? (
|
alt="Home"
|
||||||
<img
|
src={props.imageUrl}
|
||||||
alt="Home"
|
className="rounded-t-md object-cover w-full"
|
||||||
src={props.imageUrl}
|
/>
|
||||||
className="rounded-t-md object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
|
|
||||||
)}
|
|
||||||
{props.cardState === "missing" && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
|
|
||||||
<i className="icon-[solar--file-corrupted-outline] w-16 h-16 text-red-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.title ? (
|
{props.title ? (
|
||||||
<div className="px-3 pt-3 mb-2">
|
<div className="px-3 pt-3 mb-2">
|
||||||
@@ -140,7 +109,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
case "horizontal-small":
|
case "horizontal-small":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex flex-row justify-start align-top gap-3 h-fit rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
|
<div className="flex flex-row justify-start align-top gap-3 bg-slate-200 h-fit rounded-md shadow-md shadow-white-400">
|
||||||
{/* thumbnail */}
|
{/* thumbnail */}
|
||||||
<div className="rounded-md overflow-hidden">
|
<div className="rounded-md overflow-hidden">
|
||||||
<img src={props.imageUrl} className="object-cover h-20 w-20" />
|
<img src={props.imageUrl} className="object-cover h-20 w-20" />
|
||||||
@@ -156,7 +125,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
case "horizontal-medium":
|
case "horizontal-medium":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex flex-row items-center align-top gap-3 h-fit p-2 rounded-md shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-slate-200"}`}>
|
<div className="flex flex-row items-center align-top gap-3 bg-slate-200 h-fit p-2 rounded-md shadow-md shadow-white-400">
|
||||||
{/* thumbnail */}
|
{/* thumbnail */}
|
||||||
<div className="rounded-md overflow-hidden">
|
<div className="rounded-md overflow-hidden">
|
||||||
<img src={props.imageUrl} />
|
<img src={props.imageUrl} />
|
||||||
@@ -171,14 +140,31 @@ const renderCard = (props: ICardProps): ReactElement => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "cover-only":
|
case "cover-only":
|
||||||
|
const containerStyle = {
|
||||||
|
width: props.cardContainerStyle?.width || "100%",
|
||||||
|
height: props.cardContainerStyle?.height || "auto",
|
||||||
|
maxWidth: props.cardContainerStyle?.maxWidth || "none",
|
||||||
|
...props.cardContainerStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
...props.imageStyle,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{/* thumbnail */}
|
className={`rounded-2xl overflow-hidden shadow-md bg-white dark:bg-slate-800 ${
|
||||||
<div className="rounded-lg shadow-lg overflow-hidden w-fit h-fit">
|
props.cardContainerStyle?.height ? "" : "aspect-[2/3]"
|
||||||
<img src={props.imageUrl} />
|
}`}
|
||||||
</div>
|
style={containerStyle}
|
||||||
</>
|
>
|
||||||
|
<img src={props.imageUrl} alt="Comic cover" style={imageStyle} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "card-with-info-panel":
|
case "card-with-info-panel":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
type IHeaderProps = {
|
type IHeaderProps = {
|
||||||
headerContent: string;
|
headerContent: string;
|
||||||
subHeaderContent: ReactElement | string;
|
subHeaderContent: ReactElement;
|
||||||
iconClassNames: string;
|
iconClassNames: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { Card } from "../shared/Carda";
|
import { Card } from "../shared/Carda";
|
||||||
@@ -7,40 +6,57 @@ import { convert } from "html-to-text";
|
|||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import { find, isUndefined } from "lodash";
|
import { find, isUndefined } from "lodash";
|
||||||
|
|
||||||
interface IMetadatPanelProps {
|
/**
|
||||||
|
* Props for the MetadataPanel component.
|
||||||
|
*/
|
||||||
|
interface MetadataPanelProps {
|
||||||
|
/**
|
||||||
|
* Comic metadata object passed into the panel.
|
||||||
|
*/
|
||||||
data: any;
|
data: any;
|
||||||
value?: any;
|
|
||||||
children?: any;
|
/**
|
||||||
imageStyle?: any;
|
* Optional custom styling for the cover image.
|
||||||
titleStyle?: any;
|
*/
|
||||||
tagsStyle?: any;
|
imageStyle?: React.CSSProperties;
|
||||||
containerStyle?: any;
|
|
||||||
isMissing?: boolean;
|
/**
|
||||||
|
* Optional custom styling for the title section.
|
||||||
|
*/
|
||||||
|
titleStyle?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|
||||||
const { isMissing = false } = props;
|
/**
|
||||||
|
* MetadataPanel component
|
||||||
|
*
|
||||||
|
* Displays structured comic metadata based on the best available source
|
||||||
|
* (raw file data, ComicVine, or League of Comic Geeks).
|
||||||
|
*
|
||||||
|
* @component
|
||||||
|
* @param {MetadataPanelProps} props
|
||||||
|
* @returns {ReactElement}
|
||||||
|
*/
|
||||||
|
export const MetadataPanel = (props: MetadataPanelProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
inferredMetadata,
|
inferredMetadata,
|
||||||
sourcedMetadata: { comicvine, locg },
|
sourcedMetadata: { comicvine, locg },
|
||||||
} = props.data;
|
} = props.data;
|
||||||
|
|
||||||
const { issueName, url, objectReference } = determineCoverFile({
|
const { issueName, url, objectReference } = determineCoverFile({
|
||||||
comicvine,
|
comicvine,
|
||||||
locg,
|
locg,
|
||||||
rawFileDetails,
|
rawFileDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
const metadataContentPanel = [
|
const metadataContentPanel = [
|
||||||
{
|
{
|
||||||
name: "rawFileDetails",
|
name: "rawFileDetails",
|
||||||
content: () => (
|
content: () => (
|
||||||
<dl
|
<dl className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg">
|
||||||
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
|
<dt>
|
||||||
>
|
<p className="text-lg">{issueName}</p>
|
||||||
<dt className="flex items-center gap-2">
|
|
||||||
<p className="text-sm sm:text-lg">{issueName}</p>
|
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-xs sm:text-sm">
|
<dd className="text-sm">
|
||||||
is a part of{" "}
|
is a part of{" "}
|
||||||
<span className="underline">
|
<span className="underline">
|
||||||
{inferredMetadata.issue.name}
|
{inferredMetadata.issue.name}
|
||||||
@@ -48,57 +64,29 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
{/* Issue number */}
|
|
||||||
{inferredMetadata.issue.number && (
|
{inferredMetadata.issue.number && (
|
||||||
<dd className="my-2">
|
<dd className="my-2">
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
|
||||||
<span className="pr-1 pt-1">
|
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70"></i>
|
||||||
<i className="icon-[solar--hashtag-outline] w-3.5 h-3.5"></i>
|
<span>{inferredMetadata.issue.number}</span>
|
||||||
</span>
|
|
||||||
<span className="text-xs sm:text-md text-slate-900 dark:text-slate-900">
|
|
||||||
{inferredMetadata.issue.number}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
)}
|
)}
|
||||||
<dd className="flex flex-row flex-wrap gap-1 sm:gap-2 w-full sm:w-max">
|
|
||||||
{/* File extension */}
|
|
||||||
{rawFileDetails.mimeType && (
|
|
||||||
<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-4 h-4 sm:w-5 sm:h-5"></i>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
|
||||||
{rawFileDetails.mimeType}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* size */}
|
<dd className="flex flex-row gap-2 w-max">
|
||||||
{rawFileDetails.fileSize != null && (
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
|
||||||
<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">
|
<i className="icon-[solar--file-text-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
<span className="pr-1 pt-1">
|
{rawFileDetails.mimeType}
|
||||||
<i className="icon-[solar--database-bold-duotone] w-4 h-4 sm:w-5 sm:h-5"></i>
|
</span>
|
||||||
</span>
|
|
||||||
<span className="text-xs sm:text-md text-slate-500 dark:text-slate-900">
|
|
||||||
{prettyBytes(rawFileDetails.fileSize)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Missing file Icon */}
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-400 text-slate-800 dark:text-slate-900 text-xs font-medium px-2 py-1 rounded-md">
|
||||||
{isMissing && (
|
<i className="icon-[solar--database-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
<span className="pr-2 pt-1" title="File backing this comic is missing">
|
{prettyBytes(rawFileDetails.fileSize)}
|
||||||
<i className="icon-[solar--file-corrupted-outline] w-5 h-5 text-red-600 shrink-0"></i>
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Uncompressed version available? */}
|
|
||||||
{rawFileDetails.archive?.uncompressed && (
|
{rawFileDetails.archive?.uncompressed && (
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
<span className="pr-1 pt-1">
|
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5 pr-1 pt-1" />
|
||||||
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -108,49 +96,56 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
name: "comicvine",
|
name: "comicvine",
|
||||||
content: () =>
|
content: () => {
|
||||||
!isUndefined(comicvine) &&
|
return (
|
||||||
!isUndefined(comicvine.volumeInformation) && (
|
!isUndefined(comicvine?.volumeInformation) && (
|
||||||
<dl>
|
<dl className="space-y-1 text-sm text-slate-700 dark:text-slate-200">
|
||||||
<dt>
|
{/* Title */}
|
||||||
<h6
|
<dt className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
className="name has-text-weight-medium mb-1"
|
{ellipsize(issueName, 28)}
|
||||||
style={props.titleStyle}
|
</dt>
|
||||||
>
|
|
||||||
{ellipsize(issueName, 18)}
|
{/* Volume Name */}
|
||||||
</h6>
|
<dd>
|
||||||
</dt>
|
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
<dd>
|
Part of{" "}
|
||||||
<span className="is-size-7">
|
<span className="font-medium text-slate-800 dark:text-white">
|
||||||
Is a part of{" "}
|
{comicvine.volumeInformation.name}
|
||||||
<span className="has-text-weight-semibold">
|
</span>
|
||||||
{comicvine.volumeInformation.name}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</dd>
|
||||||
</dd>
|
|
||||||
<dd className="is-size-7">
|
{/* Description */}
|
||||||
<span>
|
<dd className="text-slate-600 dark:text-slate-300">
|
||||||
{ellipsize(
|
{ellipsize(
|
||||||
convert(comicvine.description, {
|
convert(comicvine.description || "", {
|
||||||
baseElements: {
|
baseElements: { selectors: ["p"] },
|
||||||
selectors: ["p"],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
120,
|
160,
|
||||||
)}
|
)}
|
||||||
</span>
|
</dd>
|
||||||
</dd>
|
|
||||||
<dd className="is-size-7 mt-2">
|
{/* Misc Info */}
|
||||||
<span className="my-3 mx-2">
|
<dd className="flex flex-wrap items-center gap-2 pt-2 text-xs text-slate-500 dark:text-slate-300">
|
||||||
{comicvine.volumeInformation.start_year}
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
|
||||||
</span>
|
<i className="icon-[solar--calendar-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
{comicvine.volumeInformation.count_of_issues}
|
{comicvine.volumeInformation.start_year}
|
||||||
ComicVine ID
|
</span>
|
||||||
{comicvine.id}
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
|
||||||
</dd>
|
<i className="icon-[solar--book-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
</dl>
|
{comicvine.volumeInformation.count_of_issues} issues
|
||||||
),
|
</span>
|
||||||
|
<span className="inline-flex items-center bg-slate-100 dark:bg-slate-600 px-2 py-0.5 rounded-md">
|
||||||
|
<i className="icon-[solar--hashtag-bold-duotone] w-4 h-4 mr-1 opacity-70" />
|
||||||
|
ID: {comicvine.id}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "locg",
|
name: "locg",
|
||||||
content: () => (
|
content: () => (
|
||||||
@@ -161,23 +156,22 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
</h6>
|
</h6>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="is-size-7">
|
<dd className="is-size-7">
|
||||||
<span>{ellipsize(locg.description, 120)}</span>
|
<span>{ellipsize(locg?.description || "", 120)}</span>
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dd className="is-size-7 mt-2">
|
<dd className="is-size-7 mt-2">
|
||||||
<div className="field is-grouped is-grouped-multiline">
|
<div className="field is-grouped is-grouped-multiline">
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<span className="tags">
|
<span className="tags">
|
||||||
<span className="tag is-success is-light has-text-weight-semibold">
|
<span className="tag is-success is-light has-text-weight-semibold">
|
||||||
{locg.price}
|
{locg?.price}
|
||||||
</span>
|
</span>
|
||||||
<span className="tag is-success is-light">{locg.pulls}</span>
|
<span className="tag is-success is-light">{locg?.pulls}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<div className="tags has-addons">
|
<div className="tags has-addons">
|
||||||
<span className="tag is-primary is-light">rating</span>
|
<span className="tag is-primary is-light">rating</span>
|
||||||
<span className="tag is-info is-light">{locg.rating}</span>
|
<span className="tag is-info is-light">{locg?.rating}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,21 +181,19 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Find the panel to display
|
|
||||||
const metadataPanel = find(metadataContentPanel, {
|
const metadataPanel = find(metadataContentPanel, {
|
||||||
name: objectReference,
|
name: objectReference,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
|
<div className="flex gap-5 my-3">
|
||||||
<div className="w-32 sm:w-56 lg:w-52 shrink-0">
|
<Card
|
||||||
<Card
|
imageUrl={url}
|
||||||
imageUrl={url}
|
orientation="cover-only"
|
||||||
orientation={"cover-only"}
|
hasDetails={false}
|
||||||
hasDetails={false}
|
imageStyle={props.imageStyle}
|
||||||
imageStyle={props.imageStyle}
|
cardContainerStyle={{ width: "190px", maxWidth: "230px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div>{metadataPanel?.content()}</div>
|
||||||
<div className="flex-1">{metadataPanel?.content()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,22 +3,22 @@ import { Link } from "react-router-dom";
|
|||||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||||
|
|
||||||
export const Navbar2 = (): ReactElement => {
|
export const Navbar2 = (): ReactElement => {
|
||||||
const [theme, setTheme] = useDarkMode();
|
const [colorTheme, setTheme] = useDarkMode();
|
||||||
const darkMode = theme === "dark";
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = (checked) => {
|
||||||
setTheme(darkMode ? "light" : "dark");
|
setTheme(colorTheme);
|
||||||
|
setDarkMode(!darkMode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-900 border-b-2 border-gray-300 dark:border-slate-200">
|
<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">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-5">
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-8">
|
<div className="mx-auto flex">
|
||||||
{/* Logo */}
|
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
|
||||||
<img src="/src/client/assets/img/threetwo.png" alt="ThreeTwo!" />
|
|
||||||
|
|
||||||
{/* Main Navigation */}
|
{/* Main Navigation */}
|
||||||
<div className="flex flex-1 items-center justify-end md:justify-between">
|
<div className="flex flex-1 items-center justify-end md:justify-between">
|
||||||
<nav
|
<nav
|
||||||
aria-label="ThreeTwo Main Navigation"
|
aria-label="ThreeTwo Main Navigation"
|
||||||
className="hidden md:block"
|
className="hidden md:block"
|
||||||
@@ -79,49 +79,48 @@ export const Navbar2 = (): ReactElement => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right-most Nav */}
|
{/* Right-most Nav */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<ul className="flex items-center gap-6 text-md">
|
<ul className="flex items-center gap-6 text-md">
|
||||||
{/* Settings Icon and text */}
|
{/* Settings Icon and text */}
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
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"
|
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">
|
<span className="w-5 h-5">
|
||||||
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
|
<i className="icon-[solar--settings-outline] h-5 w-5"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
{/* Light/Dark Mode toggle */}
|
{/* Light/Dark Mode toggle */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-gray-600 dark:text-white">Light</span>
|
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||||
<label
|
<label
|
||||||
htmlFor="toggle"
|
htmlFor="toggle"
|
||||||
className="relative inline-flex items-center"
|
className="relative inline-flex items-center"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="toggle"
|
id="toggle"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
checked={darkMode}
|
checked={darkMode}
|
||||||
onChange={toggleDarkMode}
|
onChange={toggleDarkMode}
|
||||||
/>
|
/>
|
||||||
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
|
<span className="bg-gray-300 w-10 h-6 rounded-full"></span>
|
||||||
<span
|
<span
|
||||||
className={`bg-white w-4 h-4 rounded-full absolute left-1 top-1 transition-transform duration-300 ease-in-out ${
|
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" : ""
|
darkMode ? "translate-x-4" : ""
|
||||||
}`}
|
}`}
|
||||||
></span>
|
></span>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
<span className="text-gray-600 dark:text-white">Light</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const PopoverButton = ({ content, clickHandler }) => {
|
|||||||
onFocus={() => setIsVisible(true)}
|
onFocus={() => setIsVisible(true)}
|
||||||
onBlur={() => setIsVisible(false)}
|
onBlur={() => setIsVisible(false)}
|
||||||
aria-describedby="popover"
|
aria-describedby="popover"
|
||||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
className="flex text-sm 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-1.5 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
>
|
>
|
||||||
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||||
@@ -32,7 +32,7 @@ const PopoverButton = ({ content, clickHandler }) => {
|
|||||||
<div
|
<div
|
||||||
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
|
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
|
||||||
style={floatingStyles}
|
style={floatingStyles}
|
||||||
className="text-sm bg-slate-400 p-2 rounded-md"
|
className="text-xs bg-slate-400 p-1.5 rounded-md"
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,89 +1,69 @@
|
|||||||
import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
import React, { ReactElement, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
Row,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
PaginationState,
|
PaginationState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
/** Props for {@link T2Table}. */
|
interface T2TableProps {
|
||||||
interface T2TableProps<TData> {
|
sourceData?: unknown[];
|
||||||
/** Row data to render. */
|
|
||||||
sourceData?: TData[];
|
|
||||||
/** Total number of records across all pages, used for pagination display. */
|
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
/** Column definitions (TanStack Table {@link ColumnDef} array). */
|
columns?: unknown[];
|
||||||
columns?: ColumnDef<TData>[];
|
|
||||||
/** Callbacks for navigating between pages. */
|
|
||||||
paginationHandlers?: {
|
paginationHandlers?: {
|
||||||
nextPage?(pageIndex: number, pageSize: number): void;
|
nextPage?(...args: unknown[]): unknown;
|
||||||
previousPage?(pageIndex: number, pageSize: number): void;
|
previousPage?(...args: unknown[]): unknown;
|
||||||
};
|
};
|
||||||
/** Called with the TanStack row object when a row is clicked. */
|
rowClickHandler?(...args: unknown[]): unknown;
|
||||||
rowClickHandler?(row: Row<TData>): void;
|
children?: any;
|
||||||
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
|
|
||||||
getRowClassName?(row: Row<TData>): string;
|
|
||||||
/** Optional slot rendered in the toolbar area (e.g. a search input). */
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||||
* A paginated data table with a two-row sticky header.
|
const {
|
||||||
*
|
sourceData,
|
||||||
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
|
columns,
|
||||||
* element placed immediately before the table. The second header row's `top`
|
paginationHandlers: { nextPage, previousPage },
|
||||||
* offset is measured at mount time so both rows stay flush regardless of font
|
totalPages,
|
||||||
* size or padding changes.
|
rowClickHandler,
|
||||||
*/
|
} = tableOptions;
|
||||||
export const T2Table = <TData,>({
|
|
||||||
sourceData = [],
|
|
||||||
columns = [],
|
|
||||||
paginationHandlers: { nextPage, previousPage } = {},
|
|
||||||
totalPages = 0,
|
|
||||||
rowClickHandler,
|
|
||||||
getRowClassName,
|
|
||||||
children,
|
|
||||||
}: T2TableProps<TData>): ReactElement => {
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
|
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
|
||||||
const [firstRowHeight, setFirstRowHeight] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => setIsSticky(!entry.isIntersecting),
|
|
||||||
{ threshold: 0 },
|
|
||||||
);
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (firstHeaderRowRef.current)
|
|
||||||
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 1,
|
pageIndex: 1,
|
||||||
pageSize: 15,
|
pageSize: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
/** Advances to the next page and notifies the parent. */
|
/**
|
||||||
|
* Pagination control to move forward one page
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
const goToNextPage = () => {
|
const goToNextPage = () => {
|
||||||
setPagination({ pageIndex: pageIndex + 1, pageSize });
|
setPagination({
|
||||||
nextPage?.(pageIndex, pageSize);
|
pageIndex: pageIndex + 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
nextPage(pageIndex, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Goes back one page and notifies the parent. */
|
/**
|
||||||
|
* Pagination control to move backward one page
|
||||||
|
* @returns void
|
||||||
|
**/
|
||||||
const goToPreviousPage = () => {
|
const goToPreviousPage = () => {
|
||||||
setPagination({ pageIndex: pageIndex - 1, pageSize });
|
setPagination({
|
||||||
previousPage?.(pageIndex, pageSize);
|
pageIndex: pageIndex - 1,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
previousPage(pageIndex, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -91,63 +71,64 @@ export const T2Table = <TData,>({
|
|||||||
columns,
|
columns,
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
pageCount: sourceData.length ?? -1,
|
pageCount: sourceData?.length ?? -1,
|
||||||
state: { pagination },
|
state: {
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-fit">
|
<div className="container max-w-fit mx-14">
|
||||||
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
<div>
|
||||||
{children}
|
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
|
||||||
|
{/* Search bar */}
|
||||||
|
{tableOptions.children}
|
||||||
|
|
||||||
<div className="text-sm text-gray-800 dark:text-slate-200">
|
{/* Pagination controls */}
|
||||||
<div className="mb-1">
|
<div className="text-sm text-gray-800 dark:text-slate-200">
|
||||||
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
<div className="mb-1">
|
||||||
</div>
|
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
|
||||||
<p className="text-xs text-gray-600 dark:text-slate-400">
|
</div>
|
||||||
{totalPages} comics in all
|
<p className="text-xs text-gray-600 dark:text-slate-400">
|
||||||
</p>
|
{totalPages} comics in all
|
||||||
<div className="inline-flex flex-row mt-3">
|
</p>
|
||||||
<button
|
<div className="inline-flex flex-row mt-3">
|
||||||
onClick={goToPreviousPage}
|
<button
|
||||||
disabled={pageIndex === 1}
|
onClick={() => goToPreviousPage()}
|
||||||
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
disabled={pageIndex === 1}
|
||||||
>
|
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
|
||||||
<i className="icon-[solar--arrow-left-linear] h-5 w-5" />
|
>
|
||||||
</button>
|
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i>
|
||||||
<button
|
</button>
|
||||||
onClick={goToNextPage}
|
<button
|
||||||
disabled={pageIndex > Math.floor(totalPages / pageSize)}
|
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
|
||||||
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-5 w-5" />
|
>
|
||||||
</button>
|
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={sentinelRef} />
|
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100 border-separate border-spacing-0">
|
||||||
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
|
<thead className="sticky top-0 bg-white dark:bg-slate-900 z-10 border-b border-gray-300 dark:border-slate-700">
|
||||||
<thead>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
|
<tr key={headerGroup.id}>
|
||||||
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
colSpan={header.colSpan}
|
colSpan={header.colSpan}
|
||||||
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
|
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400"
|
||||||
className={[
|
|
||||||
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
|
|
||||||
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
|
|
||||||
groupIndex === 0
|
|
||||||
? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
|
|
||||||
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
|
|
||||||
].join(' ')}
|
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -158,8 +139,8 @@ export const T2Table = <TData,>({
|
|||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => rowClickHandler?.(row)}
|
onClick={() => rowClickHandler(row)}
|
||||||
className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
|
className="border-b border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-3 py-2 align-top">
|
<td key={cell.id} className="px-3 py-2 align-top">
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { GetComicByIdQuery } from '../generated';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter to transform GraphQL Comic response to legacy REST API format
|
|
||||||
* This allows gradual migration while maintaining compatibility with existing components
|
|
||||||
*/
|
|
||||||
export function adaptGraphQLComicToLegacy(graphqlComic: GetComicByIdQuery['comic']) {
|
|
||||||
if (!graphqlComic) return null;
|
|
||||||
|
|
||||||
// Parse sourced metadata (GraphQL returns as strings)
|
|
||||||
const comicvine = graphqlComic.sourcedMetadata?.comicvine
|
|
||||||
? (typeof graphqlComic.sourcedMetadata.comicvine === 'string'
|
|
||||||
? JSON.parse(graphqlComic.sourcedMetadata.comicvine)
|
|
||||||
: graphqlComic.sourcedMetadata.comicvine)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const comicInfo = graphqlComic.sourcedMetadata?.comicInfo
|
|
||||||
? (typeof graphqlComic.sourcedMetadata.comicInfo === 'string'
|
|
||||||
? JSON.parse(graphqlComic.sourcedMetadata.comicInfo)
|
|
||||||
: graphqlComic.sourcedMetadata.comicInfo)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const locg = graphqlComic.sourcedMetadata?.locg || undefined;
|
|
||||||
|
|
||||||
// Use inferredMetadata from GraphQL response, or build from canonical metadata as fallback
|
|
||||||
const inferredMetadata = graphqlComic.inferredMetadata || {
|
|
||||||
issue: {
|
|
||||||
name: graphqlComic.canonicalMetadata?.title?.value ||
|
|
||||||
graphqlComic.canonicalMetadata?.series?.value ||
|
|
||||||
graphqlComic.rawFileDetails?.name || '',
|
|
||||||
number: graphqlComic.canonicalMetadata?.issueNumber?.value
|
|
||||||
? parseInt(graphqlComic.canonicalMetadata.issueNumber.value, 10)
|
|
||||||
: undefined,
|
|
||||||
year: graphqlComic.canonicalMetadata?.publicationDate?.value?.substring(0, 4) ||
|
|
||||||
graphqlComic.canonicalMetadata?.coverDate?.value?.substring(0, 4),
|
|
||||||
subtitle: graphqlComic.canonicalMetadata?.series?.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build acquisition data (if available from importStatus or other fields)
|
|
||||||
const acquisition = {
|
|
||||||
directconnect: {
|
|
||||||
downloads: [],
|
|
||||||
},
|
|
||||||
torrent: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Transform rawFileDetails to match expected format
|
|
||||||
const rawFileDetails = graphqlComic.rawFileDetails ? {
|
|
||||||
name: graphqlComic.rawFileDetails.name || '',
|
|
||||||
filePath: graphqlComic.rawFileDetails.filePath,
|
|
||||||
fileSize: graphqlComic.rawFileDetails.fileSize,
|
|
||||||
extension: graphqlComic.rawFileDetails.extension,
|
|
||||||
mimeType: graphqlComic.rawFileDetails.mimeType,
|
|
||||||
containedIn: graphqlComic.rawFileDetails.containedIn,
|
|
||||||
pageCount: graphqlComic.rawFileDetails.pageCount,
|
|
||||||
archive: graphqlComic.rawFileDetails.archive,
|
|
||||||
cover: graphqlComic.rawFileDetails.cover,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
_id: graphqlComic.id,
|
|
||||||
rawFileDetails,
|
|
||||||
inferredMetadata,
|
|
||||||
sourcedMetadata: {
|
|
||||||
comicvine,
|
|
||||||
locg,
|
|
||||||
comicInfo,
|
|
||||||
},
|
|
||||||
acquisition,
|
|
||||||
createdAt: graphqlComic.createdAt || new Date().toISOString(),
|
|
||||||
updatedAt: graphqlComic.updatedAt || new Date().toISOString(),
|
|
||||||
// Include the full GraphQL data for components that can use it
|
|
||||||
__graphql: graphqlComic,
|
|
||||||
} as any; // Use 'as any' to bypass strict type checking during migration
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { LIBRARY_SERVICE_HOST } from '../constants/endpoints';
|
|
||||||
|
|
||||||
export function fetcher<TData, TVariables>(
|
|
||||||
query: string,
|
|
||||||
variables?: TVariables,
|
|
||||||
options?: RequestInit['headers']
|
|
||||||
) {
|
|
||||||
return async (): Promise<TData> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${LIBRARY_SERVICE_HOST}/graphql`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
variables,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the response is OK (status 200-299)
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
if (json.errors) {
|
|
||||||
const { message } = json.errors[0] || {};
|
|
||||||
throw new Error(message || 'Error fetching data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.data;
|
|
||||||
} catch (error) {
|
|
||||||
// Handle network errors or other fetch failures
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw new Error(`Failed to fetch: ${error.message}`);
|
|
||||||
}
|
|
||||||
throw new Error('Failed to fetch data from server');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
importStatus {
|
|
||||||
isRawFileMissing
|
|
||||||
}
|
|
||||||
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
|
|
||||||
comicsMissingFiles
|
|
||||||
comicDirectorySize {
|
|
||||||
fileCount
|
|
||||||
totalSizeInGB
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
query GetImportStatistics($directoryPath: String) {
|
|
||||||
getImportStatistics(directoryPath: $directoryPath) {
|
|
||||||
success
|
|
||||||
directory
|
|
||||||
stats {
|
|
||||||
totalLocalFiles
|
|
||||||
alreadyImported
|
|
||||||
newFiles
|
|
||||||
missingFiles
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 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);
|
localStorage.setItem("theme", theme);
|
||||||
}, [theme, colorTheme]);
|
}, [theme, colorTheme]);
|
||||||
|
|
||||||
return [theme, setTheme];
|
return [colorTheme, setTheme];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
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);
|
|
||||||
// Only true if IMPORT_SESSION_STARTED fired in this browser session.
|
|
||||||
// Prevents a stale "running" DB session from showing as active on hard refresh.
|
|
||||||
const sessionStartedEventReceived = useRef(false);
|
|
||||||
|
|
||||||
// Query active import session - polls every 3s as a fallback when a session is
|
|
||||||
// active (e.g. tab re-opened mid-import and socket events were missed)
|
|
||||||
const { data: sessionData, refetch } = useGetActiveImportSessionQuery(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const s = (query.state.data as any)?.getActiveImportSession;
|
|
||||||
return s?.status === "running" || s?.status === "active" || s?.status === "processing"
|
|
||||||
? 3000
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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") {
|
|
||||||
const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued;
|
|
||||||
// Only treat as "just started" if the started event fired in this browser session.
|
|
||||||
// Prevents a stale DB session from showing a 0% progress bar on hard refresh.
|
|
||||||
const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current;
|
|
||||||
|
|
||||||
// No in-session event AND no actual progress → stale unclosed session from a previous run.
|
|
||||||
// Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded.
|
|
||||||
const likelyStale = !sessionStartedEventReceived.current
|
|
||||||
&& stats.filesProcessed === 0
|
|
||||||
&& stats.filesSucceeded === 0;
|
|
||||||
|
|
||||||
if ((hasQueuedWork || justStarted) && !likelyStale) {
|
|
||||||
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 all files processed — likely a stale session
|
|
||||||
console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, 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 / LS_INCREMENTAL_IMPORT_STARTED event received");
|
|
||||||
// Reset completion flags when new session starts
|
|
||||||
completionEventReceived.current = false;
|
|
||||||
queueDrainedEventReceived.current = false;
|
|
||||||
sessionStartedEventReceived.current = true;
|
|
||||||
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("LS_INCREMENTAL_IMPORT_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("LS_INCREMENTAL_IMPORT_STARTED", handleSessionStarted);
|
|
||||||
socket.off("IMPORT_SESSION_UPDATED", handleSessionUpdated);
|
|
||||||
};
|
|
||||||
}, [getSocket, refetch]);
|
|
||||||
|
|
||||||
return sessionState;
|
|
||||||
};
|
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import React from "react";
|
||||||
|
import { render } from "react-dom";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
const root = createRoot(document.getElementById("root")!);
|
|
||||||
import App from "./components/App";
|
import App from "./components/App";
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
import Settings from "./components/Settings/Settings";
|
||||||
import { ErrorPage } from "./components/shared/ErrorPage";
|
import { ErrorPage } from "./components/shared/ErrorPage";
|
||||||
import "./shared/utils/i18n.util";
|
const rootEl = document.getElementById("root");
|
||||||
|
const root = createRoot(rootEl);
|
||||||
|
import i18n from "./shared/utils/i18n.util";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 Settings = lazy(() => import("./components/Settings/Settings"));
|
const queryClient = new QueryClient();
|
||||||
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([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -54,8 +48,6 @@ const router = createBrowserRouter([
|
|||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Suspense>
|
<RouterProvider router={router} />
|
||||||
<RouterProvider router={router} />
|
|
||||||
</Suspense>
|
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const onSearchSent = async (item, instance, unsubscribe, searchInfo) => {
|
|||||||
|
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
// We have results, download the best one
|
// We have results, download the best one
|
||||||
|
console.log("SASAAAA", results);
|
||||||
// const result = results[0];
|
// const result = results[0];
|
||||||
// SocketService.post(`search/${instance.id}/results/${result.id}/download`, {
|
// SocketService.post(`search/${instance.id}/results/${result.id}/download`, {
|
||||||
// priority: Utils.toApiPriority(item.priority),
|
// priority: Utils.toApiPriority(item.priority),
|
||||||
|
|||||||
@@ -44,23 +44,21 @@ export const determineCoverFile = (data): any => {
|
|||||||
};
|
};
|
||||||
// comicvine
|
// comicvine
|
||||||
if (!isEmpty(data.comicvine)) {
|
if (!isEmpty(data.comicvine)) {
|
||||||
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
|
coverFile.comicvine.url = data?.comicvine?.image.small_url;
|
||||||
coverFile.comicvine.issueName = data.comicvine?.name;
|
coverFile.comicvine.issueName = data.comicvine?.name;
|
||||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||||
}
|
}
|
||||||
// rawFileDetails
|
// rawFileDetails
|
||||||
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
|
if (!isEmpty(data.rawFileDetails)) {
|
||||||
const encodedFilePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||||
);
|
);
|
||||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||||
} else if (!isEmpty(data.rawFileDetails)) {
|
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
|
||||||
}
|
}
|
||||||
// wanted
|
// wanted
|
||||||
|
|
||||||
if (!isNil(data.locg)) {
|
if (!isUndefined(data.locg)) {
|
||||||
coverFile.locg.url = data.locg.cover;
|
coverFile.locg.url = data.locg.cover;
|
||||||
coverFile.locg.issueName = data.locg.name;
|
coverFile.locg.issueName = data.locg.name;
|
||||||
coverFile.locg.publisher = data.locg.publisher;
|
coverFile.locg.publisher = data.locg.publisher;
|
||||||
@@ -68,15 +66,14 @@ export const determineCoverFile = (data): any => {
|
|||||||
|
|
||||||
const result = filter(coverFile, (item) => item.url !== "");
|
const result = filter(coverFile, (item) => item.url !== "");
|
||||||
|
|
||||||
if (result.length >= 1) {
|
if (result.length > 1) {
|
||||||
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
const highestPriorityCoverFile = minBy(result, (item) => item.priority);
|
||||||
if (!isUndefined(highestPriorityCoverFile)) {
|
if (!isUndefined(highestPriorityCoverFile)) {
|
||||||
return highestPriorityCoverFile;
|
return highestPriorityCoverFile;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return result[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cover URL available — return rawFile entry so the name is still shown
|
|
||||||
return coverFile.rawFile;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const determineExternalMetadata = (
|
export const determineExternalMetadata = (
|
||||||
@@ -88,8 +85,8 @@ export const determineExternalMetadata = (
|
|||||||
case "comicvine":
|
case "comicvine":
|
||||||
return {
|
return {
|
||||||
coverURL:
|
coverURL:
|
||||||
source.comicvine?.image?.small_url ||
|
source.comicvine?.image.small_url ||
|
||||||
source.comicvine?.volumeInformation?.image?.small_url,
|
source.comicvine.volumeInformation?.image.small_url,
|
||||||
issue: source.comicvine.name,
|
issue: source.comicvine.name,
|
||||||
icon: "cvlogo.svg",
|
icon: "cvlogo.svg",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ const getIssueTypeDisplayName = (
|
|||||||
return { displayName, results };
|
return { displayName, results };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handling could be added here if needed
|
console.log(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,122 +1,177 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import io, { Socket } from "socket.io-client";
|
import io, { Socket } from "socket.io-client";
|
||||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { isNil } from "lodash";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import "react-toastify/dist/ReactToastify.css";
|
import "react-toastify/dist/ReactToastify.min.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
// Type for global state
|
||||||
|
|
||||||
/**
|
|
||||||
* Global application state interface
|
|
||||||
*/
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
/** Active socket.io connections by namespace */
|
|
||||||
socketInstances: Record<string, Socket>;
|
socketInstances: Record<string, Socket>;
|
||||||
/**
|
|
||||||
* Get or create socket connection for namespace
|
|
||||||
* @param namespace - Socket namespace (default: "/")
|
|
||||||
* @returns Socket instance
|
|
||||||
*/
|
|
||||||
getSocket: (namespace?: string) => Socket;
|
getSocket: (namespace?: string) => Socket;
|
||||||
/**
|
|
||||||
* Disconnect and remove socket instance
|
|
||||||
* @param namespace - Socket namespace to disconnect
|
|
||||||
*/
|
|
||||||
disconnectSocket: (namespace: string) => void;
|
disconnectSocket: (namespace: string) => void;
|
||||||
/** ComicVine scraping state */
|
queryClientRef: { current: any } | null;
|
||||||
|
setQueryClientRef: (ref: any) => void;
|
||||||
|
|
||||||
comicvine: {
|
comicvine: {
|
||||||
scrapingStatus: string;
|
scrapingStatus: string;
|
||||||
};
|
};
|
||||||
/** Import job queue state and actions */
|
|
||||||
importJobQueue: {
|
importJobQueue: {
|
||||||
successfulJobCount: number;
|
successfulJobCount: number;
|
||||||
failedJobCount: number;
|
failedJobCount: number;
|
||||||
status: string | undefined;
|
status: string | undefined;
|
||||||
mostRecentImport: string | null;
|
mostRecentImport: string | null;
|
||||||
|
|
||||||
setStatus: (status: string) => void;
|
setStatus: (status: string) => void;
|
||||||
setJobCount: (jobType: string, count: number) => void;
|
setJobCount: (jobType: string, count: number) => void;
|
||||||
setMostRecentImport: (fileName: string) => void;
|
setMostRecentImport: (fileName: string) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Zustand store for global app state and socket management
|
|
||||||
*/
|
|
||||||
export const useStore = create<StoreState>((set, get) => ({
|
export const useStore = create<StoreState>((set, get) => ({
|
||||||
socketInstances: {},
|
socketInstances: {},
|
||||||
|
queryClientRef: null,
|
||||||
|
setQueryClientRef: (ref: any) => set({ queryClientRef: ref }),
|
||||||
|
|
||||||
getSocket: (namespace = "/") => {
|
getSocket: (namespace = "/") => {
|
||||||
const ns = namespace === "/" ? "" : namespace;
|
const fullNamespace = namespace === "/" ? "" : namespace;
|
||||||
const existing = get().socketInstances[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 && existing.connected) return existing;
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const sessionId = localStorage.getItem("sessionId");
|
const sessionId = localStorage.getItem("sessionId");
|
||||||
const socket = io(`${SOCKET_BASE_URI}${ns}`, {
|
const socket = io(`${SOCKET_BASE_URI}${fullNamespace}`, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket"],
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
query: { sessionId },
|
query: { sessionId },
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("sessionInitialized", (id) => localStorage.setItem("sessionId", id));
|
socket.on("connect", () => {
|
||||||
if (sessionId) socket.emit("call", "socket.resumeSession", { sessionId, namespace });
|
console.log(`✅ Connected to ${namespace}:`, socket.id);
|
||||||
|
|
||||||
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 }) =>
|
if (sessionId) {
|
||||||
set((s) => ({ comicvine: { ...s.comicvine, scrapingStatus: message } }))
|
socket.emit("call", "socket.resumeSession", { sessionId, namespace });
|
||||||
);
|
} else {
|
||||||
|
socket.on("sessionInitialized", (id) => {
|
||||||
|
localStorage.setItem("sessionId", id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
socket.on("searchResultsAvailable", ({ query }) =>
|
socket.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
||||||
toast(`Results found for query: ${JSON.stringify(query, null, 2)}`)
|
const { completedJobCount, failedJobCount, queueStatus } = data;
|
||||||
);
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
successfulJobCount: completedJobCount,
|
||||||
|
failedJobCount,
|
||||||
|
status: queueStatus,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_COVER_EXTRACTED", ({ completedJobCount, importResult }) => {
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
successfulJobCount: completedJobCount,
|
||||||
|
mostRecentImport: importResult.data.rawFileDetails.name,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_COVER_EXTRACTION_FAILED", ({ failedJobCount }) => {
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
failedJobCount,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("LS_IMPORT_QUEUE_DRAINED", () => {
|
||||||
|
localStorage.removeItem("sessionId");
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
status: "drained",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const queryClientRef = get().queryClientRef;
|
||||||
|
if (queryClientRef?.current) {
|
||||||
|
queryClientRef.current.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("CV_SCRAPING_STATUS", (data) => {
|
||||||
|
set((state) => ({
|
||||||
|
comicvine: {
|
||||||
|
...state.comicvine,
|
||||||
|
scrapingStatus: data.message,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("searchResultsAvailable", (data) => {
|
||||||
|
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
socketInstances: {
|
||||||
|
...state.socketInstances,
|
||||||
|
[namespace]: socket,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
set((s) => ({ socketInstances: { ...s.socketInstances, [namespace]: socket } }));
|
|
||||||
return socket;
|
return socket;
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnectSocket: (namespace) => {
|
disconnectSocket: (namespace: string) => {
|
||||||
const socket = get().socketInstances[namespace];
|
const socket = get().socketInstances[namespace];
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
set((s) => {
|
set((state) => {
|
||||||
const { [namespace]: _, ...rest } = s.socketInstances;
|
const { [namespace]: _, ...rest } = state.socketInstances;
|
||||||
return { socketInstances: rest };
|
return { socketInstances: rest };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
comicvine: { scrapingStatus: "" },
|
comicvine: {
|
||||||
|
scrapingStatus: "",
|
||||||
|
},
|
||||||
|
|
||||||
importJobQueue: {
|
importJobQueue: {
|
||||||
successfulJobCount: 0,
|
successfulJobCount: 0,
|
||||||
failedJobCount: 0,
|
failedJobCount: 0,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
mostRecentImport: null,
|
mostRecentImport: null,
|
||||||
setStatus: (status) => set((s) => ({ importJobQueue: { ...s.importJobQueue, status } })),
|
|
||||||
setJobCount: (jobType, count) => set((s) => ({
|
setStatus: (status: string) =>
|
||||||
importJobQueue: { ...s.importJobQueue, [jobType === "successful" ? "successfulJobCount" : "failedJobCount"]: count }
|
set((state) => ({
|
||||||
})),
|
importJobQueue: {
|
||||||
setMostRecentImport: (fileName) => set((s) => ({ importJobQueue: { ...s.importJobQueue, mostRecentImport: fileName } })),
|
...state.importJobQueue,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setJobCount: (jobType: string, count: number) =>
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
...(jobType === "successful"
|
||||||
|
? { successfulJobCount: count }
|
||||||
|
: { failedJobCount: count }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setMostRecentImport: (fileName: string) =>
|
||||||
|
set((state) => ({
|
||||||
|
importJobQueue: {
|
||||||
|
...state.importJobQueue,
|
||||||
|
mostRecentImport: fileName,
|
||||||
|
},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||||
|
|
||||||
import { MetadataPanel } from '../components/shared/MetadataPanel';
|
import { MetadataPanel } from '../components/shared/MetadataPanel';
|
||||||
import "../assets/scss/App.css";
|
import "../assets/scss/App.scss";
|
||||||
export default {
|
export default {
|
||||||
/* 👇 The title prop is optional.
|
/* 👇 The title prop is optional.
|
||||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
* 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 { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||||
|
|
||||||
import { Card } from '../components/shared/Carda';
|
import { Card } from '../components/shared/Carda';
|
||||||
import "../assets/scss/App.css";
|
import "../assets/scss/App.scss";
|
||||||
export default {
|
export default {
|
||||||
/* 👇 The title prop is optional.
|
/* 👇 The title prop is optional.
|
||||||
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
|
* 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,6 +6,3 @@ declare module "*.png" {
|
|||||||
declare module "*.jpg";
|
declare module "*.jpg";
|
||||||
declare module "*.gif";
|
declare module "*.gif";
|
||||||
declare module "*.less";
|
declare module "*.less";
|
||||||
|
|
||||||
// Comic types are now generated from GraphQL schema
|
|
||||||
// Import from '../../graphql/generated' instead
|
|
||||||
|
|||||||
18
src/server/README.md
Normal file
18
src/server/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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.
|
||||||
41
src/server/index.ts
Normal file
41
src/server/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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}`);
|
||||||
7
src/server/interfaces/path.interface.ts
Normal file
7
src/server/interfaces/path.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface IPathRoute {
|
||||||
|
methods: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ipath {
|
||||||
|
[route: string]: IPathRoute;
|
||||||
|
}
|
||||||
1
src/server/route/README.md
Normal file
1
src/server/route/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
## Read me for routes
|
||||||
3
src/server/route/index.ts
Normal file
3
src/server/route/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import opds from "./routes/dummy.routes";
|
||||||
|
|
||||||
|
export default { opds };
|
||||||
12
src/server/route/path.ts
Normal file
12
src/server/route/path.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
5
src/server/route/router.ts
Normal file
5
src/server/route/router.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import express, { Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = express.Router();
|
||||||
|
|
||||||
|
export default router;
|
||||||
3
src/server/route/routes/dummy.routes.ts
Normal file
3
src/server/route/routes/dummy.routes.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import router from "../router";
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -5,19 +5,6 @@ module.exports = {
|
|||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
card: {
|
|
||||||
wanted: "#f2d98d",
|
|
||||||
delete: "#FFEBEE",
|
|
||||||
scraped: "#b8edbc",
|
|
||||||
uncompressed: "#FFF3E0",
|
|
||||||
imported: "#d8dab0",
|
|
||||||
missing: "#fee2e2",
|
|
||||||
info: "#cdd9eb",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["PP Object Sans Regular", "sans-serif"],
|
sans: ["PP Object Sans Regular", "sans-serif"],
|
||||||
hasklig: ["Hasklig Regular", "monospace"],
|
hasklig: ["Hasklig Regular", "monospace"],
|
||||||
@@ -31,13 +18,6 @@ module.exports = {
|
|||||||
xl: "5rem",
|
xl: "5rem",
|
||||||
"2xl": "6rem",
|
"2xl": "6rem",
|
||||||
},
|
},
|
||||||
screens: {
|
|
||||||
sm: "640px",
|
|
||||||
md: "768px",
|
|
||||||
lg: "1024px",
|
|
||||||
xl: "1280px",
|
|
||||||
"2xl": "1280px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@tsconfig/node14/tsconfig.json",
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
@@ -25,5 +26,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"exclude": ["./src/server "],
|
||||||
"include": ["./src/client/*", "./src/client/types/**/*.d.ts"]
|
"include": ["./src/client/*", "./src/client/types/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
22
tsconfig.server.json
Normal file
22
tsconfig.server.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -1,40 +1,11 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
publicDir: "public",
|
publicDir: "public",
|
||||||
base: "",
|
base: "",
|
||||||
build: {
|
build: "esnext",
|
||||||
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: {
|
esbuild: {
|
||||||
supported: {
|
supported: {
|
||||||
"top-level-await": true, //browsers can handle top-level-await features
|
"top-level-await": true, //browsers can handle top-level-await features
|
||||||
@@ -42,6 +13,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: { host: true },
|
server: { host: true },
|
||||||
plugins: [
|
plugins: [
|
||||||
|
nodeResolve({
|
||||||
|
// browser: true
|
||||||
|
exportConditions: ["node"],
|
||||||
|
}),
|
||||||
react({
|
react({
|
||||||
// Use React plugin in all *.jsx and *.tsx files
|
// Use React plugin in all *.jsx and *.tsx files
|
||||||
include: "**/*.{jsx,tsx}",
|
include: "**/*.{jsx,tsx}",
|
||||||
|
|||||||
Reference in New Issue
Block a user