Compare commits

..

38 Commits

Author SHA1 Message Date
5ff53f1e21 🏗️ Cleanup of the search results pane 2023-11-28 22:52:28 -05:00
063bee98cf 🏗️ Fixed the ADC++ search results 2023-11-28 22:35:07 -05:00
70debabb53 🔎 Added some validation to ADC++ Hubs settings form 2023-11-28 21:18:12 -05:00
96890ad1e4 🏗️ Refactoring AirDC++ search method 2023-11-28 20:43:47 -05:00
c3573e67e2 🏗️ Wiring up the DC++ search method 2023-11-28 16:26:26 -05:00
4650bedbcf 🔧 Refactoring AcquisitionPanel 2023-11-27 21:34:34 -05:00
9859dbdeab ⬆️ Bumped up Vite to 5.0 2023-11-23 16:27:18 -05:00
eb399f08e0 🦃 Thanksgiving Day bug fixes 2023-11-23 15:10:17 -05:00
f9afc5a249 🏗️ Refactoring the AirDC++ Forms 2023-11-22 18:28:23 -05:00
9e5612237b 🏗️ Refactoring AcquisitionPanel 2023-11-20 14:17:58 -05:00
21a5a6028e 🔧 Fixed the error check on Library page 2023-11-17 14:15:31 -05:00
be67348d20 🏗️ Refactored ComicDetailContainer with useQuery 2023-11-16 11:40:16 -06:00
4d2d7da5b4 🏗️ Refactoring ComicDetail page and its children 2023-11-16 09:30:41 -06:00
199ddc2859 ✏️ Changed page to offset
To better reflect what we are doing with the pagination controls
2023-11-16 09:00:42 -06:00
3699ca8b2a 🏗️ Fixed pagination and disabled states 2023-11-16 08:53:25 -06:00
b1d63b02c4 🏗️ Refactoring the Library page 2023-11-15 21:03:49 -06:00
15cba6d56a ↪️ Added cache invalidation to job statistics query 2023-11-15 15:42:25 -06:00
7e304b2b96 🏗️ Refactored the Import page 2023-11-15 15:13:49 -06:00
794fd9b3a0 🏗️ Added null check and removed useless deps 2023-11-15 10:46:36 -06:00
be51f9b8f2 🏗️ Wired up job queue control methods 2023-11-14 13:25:12 -06:00
8f9bc9dc84 🏗 Implemented setQueueAction 2023-11-13 22:01:15 -05:00
19a1532f06 🏗️ Added import queue related state 2023-11-13 21:27:58 -05:00
2dcaca425d 🏗 Wired up the event emit correctly 2023-11-13 16:42:20 -05:00
214f29f9a8 🏗️ Refactored Import page to read from global state 2023-11-09 10:21:20 -06:00
18d2624d6c 🏗️ Refactored AirDC++ session status indicator 2023-11-07 20:06:32 -06:00
936945138f ⬆️ Bumped deps 2023-11-07 13:46:54 -06:00
580307a39b 🏗️ Refactoring Navbar to read from zustand store 2023-11-07 13:41:44 -06:00
74a9c4bdb0 ↪️ Removed node-sass, added sass 2023-11-07 12:04:04 -06:00
ef75dad4e2 Adding zustand and react-query (Settings Page) (#95)
* 🌊 qBittorrent settings scaffold

* 🔧 Added scaffold for the qBittorrent connection form

* 🔧 Some refactoring

* 🔧 Cleaned up folder structure

* 🔧 Fixed broken paths

* 🔧 Cleaned up Search and Import component hierarchy

* 🔧 More path fixes

* 🔧 Tooling changes

* 📝 Qbittorrent form scaffold

* ⬆️ Bumped @dnd-kit deps

* 🧑🏼‍🔧 Fixed the hostname regex

* 🏗️ Adding fields to the settings form

* 🔧 Formatting and more layout changes

* 🔧 Added Prowlarr settings items in JSON

* 📝 Purified Card Component

* 📝 Abstracted connection form into a component

* 🏗️ Reorganized tabs

* Migrating from Redux to RTK-query

* ⬇️ Fetched qBittorrent settings

* 🏗️ Trying out react-query

* 🧩 Added react-query query to qBittorrentSettings page

* 📝 qbittorrent form RU actions first draft

* 🏗️ Added loading state check

* 🏗 Added error check state

* 🏗️ Refactored AirDCPP context using react-query

* 🏗️ Refactoring AirDCPP Settings Form with react-query

* 🔧 Removed context

* 🔧 Removing context from AirDCPP settings page

* 🔧 Fixed early init error on the store

* 🐛 Debugging AirDCPP Settings Form page

* 🧸 Zustand-ified AirDCPP Form

*  AirDCPP code cleaned up from App.tsx

*  Re-added yarn.lock
2023-11-07 11:56:29 -06:00
8bebffd95e 🌊 qBittorrent Settings Scaffold (#90)
* 🌊 qBittorrent settings scaffold

* 🔧 Added scaffold for the qBittorrent connection form

* 🔧 Some refactoring

* 🔧 Cleaned up folder structure

* 🔧 Fixed broken paths

* 🔧 Cleaned up Search and Import component hierarchy

* 🔧 More path fixes

* 🔧 Tooling changes

* 📝 Qbittorrent form scaffold

* ⬆️ Bumped @dnd-kit deps

* 🧑🏼‍🔧 Fixed the hostname regex

* 🏗️ Adding fields to the settings form

* 🔧 Formatting and more layout changes

* 🔧 Added Prowlarr settings items in JSON

* 📝 Purified Card Component

* 📝 Abstracted connection form into a component

* 🏗️ Reorganized tabs

* Migrating from Redux to RTK-query

* ⬇️ Fetched qBittorrent settings

* 🏗️ Trying out react-query

* 🧩 Added react-query query to qBittorrentSettings page

* 📝 qbittorrent form RU actions first draft

* 🏗️ Added loading state check

* 🏗 Added error check state

* 🏗️ Refactored AirDCPP context using react-query

* 🏗️ Refactoring AirDCPP Settings Form with react-query

* 🔧 Removed context

* 🔧 Removing context from AirDCPP settings page

* 🔧 Fixed early init error on the store

* 🐛 Debugging AirDCPP Settings Form page

* 🧸 Zustand-ified AirDCPP Form

*  AirDCPP code cleaned up from App.tsx

*  Re-added yarn.lock
2023-11-07 11:46:08 -06:00
dependabot[bot]
1bd3d611e4 Bump postcss from 8.4.29 to 8.4.31 (#93) 2023-10-19 08:37:49 -05:00
dependabot[bot]
825782fe13 Bump @babel/traverse from 7.22.11 to 7.23.2 (#94) 2023-10-19 08:37:41 -05:00
32f4055daa Import queue progress (#87)
* 🚥 Added service status panel scaffold

* 🐂 Support for showing import progress

* 🐂 Support for session-tracking

* 🔧 Tooling for resumable socket.io sessions

* 🧹 Minor change in socket.io connection code

* 🔧 Refactoring the Import Page

* 📝 Added more details to import statuses

* 🐂 Queue pause/resume functionality

* 🐂 Queue drain event reducer

* 🐂 Queue controls

* 🔧 Hardening the import UX

* 🔀 Bumped deps

* 🔧 Fixed the airdcpp-apisocket version

* ⛑️ Removed useless deps

* 🪡 Fixed margin on the comicinfo.xml panel on the library page

* 🏗️ Scaffold for job results

* 🔢 Removed the useless LS_IMPORT event

* 🔧 Wired up jobStatistics call

* 🧹 Cleaning up the tabulated job results

* 🔧 More finishing touches to Import UX

* 🔧 Added a console log for debugging purposes

---------

Co-authored-by: Rishi Ghan <hghan@apple.com>
2023-08-30 13:49:58 -04:00
dependabot[bot]
c20f24b1a2 Bump word-wrap from 1.2.3 to 1.2.4 (#88)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-11 14:11:53 -04:00
dependabot[bot]
e4fc28f698 Bump tough-cookie from 4.0.0 to 4.1.3 (#85)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-12 10:52:32 -07:00
dependabot[bot]
d6c183339f Bump semver from 5.7.1 to 5.7.2 (#86)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-12 10:52:20 -07:00
dependabot[bot]
0eba47e20f Bump vite from 4.2.0 to 4.2.3 (#82) 2023-06-05 22:37:43 -04:00
928bfd573e 🔧 Fix for the hostname env var when running in Docker (#81) 2023-05-16 12:41:57 -04:00
76 changed files with 6849 additions and 9697 deletions

View File

@@ -1,9 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
],
framework: {

View File

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

View File

@@ -15,28 +15,22 @@
"author": "Rishi Ghan",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.17",
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
"@dnd-kit/core": "^4.0.0",
"@dnd-kit/sortable": "^5.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@fortawesome/fontawesome-free": "^6.3.0",
"@redux-devtools/extension": "^3.2.2",
"@redux-devtools/extension": "^3.2.5",
"@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-table": "^8.5.11",
"@types/axios": "^0.14.0",
"@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3",
"@types/mime-types": "^2.1.0",
"@types/react-router-dom": "^5.3.3",
"@types/socket.io": "^3.0.2",
"@types/socket.io-client": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"airdcpp-apisocket": "2.4.5-beta.1",
"airdcpp-apisocket": "^2.5.0-beta.2",
"axios": "^1.3.4",
"axios-cache-interceptor": "^1.0.1",
"axios-rate-limit": "^1.3.0",
"babel-polyfill": "^6.26.0",
"babel-preset-minify": "^0.5.2",
"better-docs": "^2.7.2",
"babel-plugin-styled-components": "^2.1.4",
"date-fns": "^2.28.0",
"dayjs": "^1.10.6",
"ellipsize": "^0.5.1",
@@ -46,9 +40,9 @@
"final-form-arrays": "^3.0.2",
"history": "^5.3.0",
"html-to-text": "^8.1.0",
"immer": "^10.0.3",
"jsdoc": "^3.6.10",
"lodash": "^4.17.21",
"node-sass": "npm:sass",
"pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1",
"qs": "^6.10.5",
@@ -56,49 +50,42 @@
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.0",
"react-dom": "^18.1.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"react-redux": "^8.0.5",
"react-router": "^6.9.0",
"react-router-dom": "^6.9.0",
"react-select": "^5.3.2",
"react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2",
"react-slick": "^0.29.0",
"react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1",
"redux-first-history": "^5.1.1",
"redux-socket.io-middleware": "^1.0.4",
"redux-thunk": "^2.4.2",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^5.3.9",
"styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14",
"vite": "^5.0.0",
"vite-plugin-html": "^3.2.0",
"websocket": "^1.0.34",
"xregexp": "^5.0.2"
"zustand": "^4.4.6"
},
"devDependencies": {
"@babel/cli": "^7.13.10",
"@babel/core": "^7.13.10",
"@babel/plugin-syntax-top-level-await": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.13.0",
"@storybook/addon-essentials": "^7.0.0-rc.3",
"@storybook/addon-interactions": "^7.0.0-rc.3",
"@storybook/addon-links": "^7.0.0-rc.3",
"@storybook/blocks": "^7.0.0-rc.3",
"@storybook/react": "^7.0.0-rc.3",
"@storybook/react-vite": "^7.0.0-rc.3",
"@storybook/testing-library": "^0.0.14-next.1",
"@storybook/addon-essentials": "^7.4.1",
"@storybook/addon-interactions": "^7.4.1",
"@storybook/addon-links": "^7.4.1",
"@storybook/addon-onboarding": "^1.0.8",
"@storybook/blocks": "^7.4.1",
"@storybook/react": "^7.4.1",
"@storybook/react-vite": "^7.4.1",
"@storybook/testing-library": "^0.2.0",
"@tanstack/eslint-plugin-query": "^5.0.5",
"@tanstack/react-query-devtools": "^5.1.0",
"@tsconfig/node14": "^1.0.0",
"@types/ellipsize": "^0.1.1",
"@types/express": "^4.17.8",
@@ -108,33 +95,28 @@
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-redux": "^7.1.25",
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"babel-eslint": "^10.0.0",
"body-parser": "^1.19.0",
"bulma": "^0.9.4",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"docdash": "^2.0.2",
"eslint": "^8.49.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsdoc": "^46.6.0",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-storybook": "^0.6.11",
"eslint-plugin-storybook": "^0.6.13",
"express": "^4.17.1",
"install": "^0.13.0",
"jest": "^26.6.3",
"nodemon": "^1.17.3",
"npm": "^8.11.0",
"jest": "^29.6.3",
"nodemon": "^3.0.1",
"prettier": "^2.2.1",
"react-refresh": "^0.14.0",
"rimraf": "^4.1.3",
"sass": "^1.58.1",
"storybook": "^7.0.0-rc.3",
"tslint": "^6.1.3",
"typescript": "^5.0.2",
"vite": "^4.2.0"
"sass": "^1.69.5",
"storybook": "^7.3.2",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6"
}
}

View File

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

View File

@@ -33,7 +33,6 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
});
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: options,
@@ -127,7 +126,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
queryObjects,
},
});
dispatch({
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
matches: foo.data,

View File

@@ -4,13 +4,12 @@ import {
COMICVINE_SERVICE_URI,
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
SEARCH_SERVICE_BASE_URI,
JOB_QUEUE_SERVICE_BASE_URI,
} from "../constants/endpoints";
import {
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_RECENT_COMICS_FETCHED,
IMS_WANTED_COMICS_FETCHED,
CV_API_CALL_IN_PROGRESS,
@@ -22,23 +21,38 @@ import {
LS_IMPORT,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
SS_SEARCH_RESULTS_FETCHED,
SS_SEARCH_IN_PROGRESS,
FILEOPS_STATE_RESET,
LS_IMPORT_CALL_IN_PROGRESS,
LS_TOGGLE_IMPORT_QUEUE,
SS_SEARCH_FAILED,
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
WANTED_COMICS_FETCHED,
VOLUMES_FETCHED,
CV_WEEKLY_PULLLIST_FETCHED,
LIBRARY_SERVICE_HEALTH,
LS_SET_QUEUE_STATUS,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { success } from "react-notification-system-redux";
import { isNil, map } from "lodash";
import { isNil } from "lodash";
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
axios
.request({
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
method: "GET",
transformResponse: (r: string) => JSON.parse(r),
})
.then((response) => {
const { data } = response;
dispatch({
type: LIBRARY_SERVICE_HEALTH,
status: data,
});
});
};
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
return axios
.request<Array<IFolderData>>({
@@ -74,19 +88,38 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
// autoDismiss: 0,
// }),
// );
const sessionId = localStorage.getItem("sessionId");
dispatch({
type: LS_IMPORT,
meta: { remote: true },
data: {},
});
await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/newImport`,
method: "POST",
data: { sessionId },
});
};
export const toggleImportQueueStatus = (options) => async (dispatch) => {
export const getImportJobResultStatistics = () => async (dispatch) => {
const result = await axios.request({
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
method: "GET",
});
dispatch({
type: LS_TOGGLE_IMPORT_QUEUE,
meta: { remote: true },
data: { manjhul: "jigyadam", action: options.action },
type: LS_IMPORT_JOB_STATISTICS_FETCHED,
data: result.data,
});
};
export const setQueueControl =
(queueAction: string, queueStatus: string) => async (dispatch) => {
dispatch({
type: LS_SET_QUEUE_STATUS,
meta: { remote: true },
data: { queueAction, queueStatus },
});
};
/**
* Fetches comic book metadata for various types
* @return metadata for the comic book object categories

View File

@@ -3,25 +3,14 @@ import {
SETTINGS_OBJECT_FETCHED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
} from "../constants/action-types";
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} from "../reducers/settings.reducer";
import {
LIBRARY_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} from "../constants/endpoints";
export const saveSettings =
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
method: "POST",
data: { settingsPayload, settingsObjectId },
});
dispatch({
type: SETTINGS_OBJECT_FETCHED,
data: result.data,
});
};
export const getSettings = (settingsKey?) => async (dispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
@@ -67,3 +56,22 @@ export const flushDb = () => async (dispatch) => {
});
}
};
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
method: "POST",
data: hostInfo,
});
const qBittorrentClientInfo = await axios.request({
url: `${QBITTORRENT_SERVICE_BASE_URI}/getClientInfo`,
method: "GET",
});
dispatch({
type: SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
data: qBittorrentClientInfo.data,
});
};
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};

View File

@@ -13,7 +13,9 @@ $size-9: 0.7rem;
$flexSize: 4em;
$boxSpacing: 1em;
$colorText: #404646;
body {
background: #fffcc;
}
.is-size-8 {
font-size: $size-8;
}
@@ -422,7 +424,7 @@ pre {
width: 100%;
padding: 25px 0 15px 0;
position: sticky;
z-index:9999;
z-index: 9999;
background: #fffffc;
top: 50px;
}

View File

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

View File

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

View File

@@ -1,139 +1,14 @@
import React, { ReactElement, useContext, useEffect } from "react";
import Dashboard from "./Dashboard/Dashboard";
import Import from "./Import";
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
import LibraryGrid from "./Library/LibraryGrid";
import Search from "./Search";
import Settings from "./Settings";
import VolumeDetail from "./VolumeDetail/VolumeDetail";
import Downloads from "./Downloads/Downloads";
import { Routes, Route } from "react-router-dom";
import Navbar from "./Navbar";
import React, { ReactElement } from "react";
import { Outlet } from "react-router-dom";
import Navbar from "./shared/Navbar";
import "../assets/scss/App.scss";
import {
AirDCPPSocketContextProvider,
AirDCPPSocketContext,
} from "../context/AirDCPPSocket";
import { isEmpty, isUndefined } from "lodash";
import {
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
LS_SINGLE_IMPORT,
} from "../constants/action-types";
import { useDispatch, useSelector } from "react-redux";
/**
* Method that initializes an AirDC++ socket connection
* 1. Initializes event listeners for download init, tick and complete events
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
* @returns void
*/
const AirDCPPSocketComponent = (): ReactElement => {
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
useEffect(() => {
const initializeAirDCPPEventListeners = async () => {
if (
!isUndefined(airDCPPConfiguration.airDCPPState) &&
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
) {
await airDCPPConfiguration.airDCPPState.socket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
// download tick listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
dispatch({
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
downloadProgressData,
});
},
);
// download complete listener
await airDCPPConfiguration.airDCPPState.socket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
dispatch({
type: LS_SINGLE_IMPORT,
meta: { remote: true },
data: bundleData,
});
count += 1;
}
}
},
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
);
console.log(
"[AirDCPP]: Listener registered - listening to queue bundle changes",
);
console.log(
"[AirDCPP]: Listener registered - listening to transfer completion",
);
}
};
initializeAirDCPPEventListeners();
}, [airDCPPConfiguration]);
return <></>;
};
export const App = (): ReactElement => {
return (
<AirDCPPSocketContextProvider>
<div>
<AirDCPPSocketComponent />
<Navbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/import" element={<Import path={"./comics"} />} />
<Route
path="/library"
element={<TabulatedContentContainer category="library" />}
/>
<Route path="/library-grid" element={<LibraryGrid />} />
<Route path="/downloads" element={<Downloads data={{}} />} />
<Route path="/search" element={<Search />} />
<Route
path={"/comic/details/:comicObjectId"}
element={<ComicDetailContainer />}
/>
<Route
path={"/volume/details/:comicObjectId"}
element={<VolumeDetail />}
/>
<Route path="/settings" element={<Settings />} />
<Route
path="/pull-list/all"
element={<TabulatedContentContainer category="pullList" />}
/>
<Route
path="/wanted/all"
element={<TabulatedContentContainer category="wanted" />}
/>
<Route
path="/volumes/all"
element={<TabulatedContentContainer category="volumes" />}
/>
</Routes>
</div>
</AirDCPPSocketContextProvider>
<>
<Navbar />
<Outlet />
</>
);
};

View File

@@ -1,21 +1,18 @@
import React, {
useCallback,
useContext,
ReactElement,
useEffect,
useState,
} from "react";
import React, { useCallback, ReactElement, useEffect, useState } from "react";
import {
search,
downloadAirDCPPItem,
getBundlesForComic,
sleep,
} from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery } from "@tanstack/react-query";
interface IAcquisitionPanelProps {
query: any;
@@ -27,107 +24,196 @@ interface IAcquisitionPanelProps {
export const AcquisitionPanel = (
props: IAcquisitionPanelProps,
): ReactElement => {
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
} = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
})),
);
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: ["hubs"],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
});
const issueName = props.query.issue.name || "";
// const { settings } = props;
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
// Selectors for picking state
const airDCPPSearchResults = useSelector((state: RootState) => {
return state.airdcpp.searchResults;
});
const isAirDCPPSearchInProgress = useSelector(
(state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
);
const searchInfo = useSelector(
(state: RootState) => state.airdcpp.searchInfo,
);
const searchInstance: SearchInstance = useSelector(
(state: RootState) => state.airdcpp.searchInstance,
);
// const airDCPPSearchResults = useSelector((state: RootState) => {
// return state.airdcpp.searchResults;
// });
// const isAirDCPPSearchInProgress = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
// );
// const searchInfo = useSelector(
// (state: RootState) => state.airdcpp.searchInfo,
// );
// const searchInstance: SearchInstance = useSelector(
// (state: RootState) => state.airdcpp.searchInstance,
// );
// const settings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const dispatch = useDispatch();
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
interface SearchData {
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
hub_urls: string[] | undefined | null;
priority: PriorityEnum;
}
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
// Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that
// All the user has to do is hit "Search AirDC++"
useEffect(() => {
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}
}, [airDCPPConfiguration]);
// AirDC++ search query
const dcppSearchQuery = {
query: {
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (item) => item.value),
priority: 5,
};
setDcppQuery(dcppSearchQuery);
}, []);
const getDCPPSearchResults = useCallback(
async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
const search = async (data: SearchData, ADCPPSocket: any) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket();
}
const instance: SearchInstance = await ADCPPSocket.post("search");
// dispatch({
// type: AIRDCPP_SEARCH_IN_PROGRESS,
// });
// We want to get notified about every new result in order to make the user experience better
await ADCPPSocket.addListener(
`search`,
"search_result_added",
async (groupedResult) => {
// ...add the received result in the UI
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
setAirDCPPSearchResults((state) => [...state, groupedResult]);
},
hub_urls: map(
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
(item) => item.value,
),
priority: 5,
};
dispatch(
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
}),
instance.id,
);
},
[dispatch, airDCPPConfiguration],
);
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
await ADCPPSocket.addListener(
`search`,
"search_result_updated",
async (groupedResult) => {
// ...update properties of the existing result in the UI
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
(bundle) => bundle.result.id === groupedResult.result.id,
);
const updatedState = [...airDCPPSearchResults];
if (
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
) {
updatedState[bundleToUpdateIndex] = groupedResult;
}
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
},
instance.id,
);
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
await ADCPPSocket.addListener(
`search`,
"search_hub_searches_sent",
async (searchInfo) => {
await sleep(5000);
// Check the number of received results (in real use cases we should know that even without calling the API)
const currentInstance = await ADCPPSocket.get(
`search/${instance.id}`,
);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
}
// The search can now be considered to be "complete"
// If there's an "in progress" indicator in the UI, that could also be disabled here
// dispatch({
// type: AIRDCPP_HUB_SEARCHES_SENT,
// searchInfo,
// instance,
// });
},
instance.id,
);
// Finally, perform the actual search
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
} catch (error) {
console.log(error);
throw error;
}
};
const getDCPPSearchResults = async (searchQuery) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
extensions: ["cbz", "cbr", "cb7"],
},
hub_urls: map(hubs, (hub) => hub.hub_url),
priority: 5,
};
search(manualQuery, airDCPPSocketInstance);
};
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
dispatch(
downloadAirDCPPItem(
searchInstanceId,
resultId,
props.comicObjectId,
name,
size,
type,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
// dispatch(
// downloadAirDCPPItem(
// searchInstanceId,
// resultId,
// props.comicObjectId,
// name,
// size,
// type,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
// this is to update the download count badge on the downloads tab
dispatch(
getBundlesForComic(
props.comicObjectId,
airDCPPConfiguration.airDCPPState.socket,
{
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
},
),
);
// dispatch(
// getBundlesForComic(
// props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
},
[airDCPPConfiguration],
[],
);
console.log("yaman", airDCPPSearchResults);
return (
<>
<div className="comic-detail columns">
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
{!isEmpty(airDCPPSocketInstance) ? (
<Form
onSubmit={getDCPPSearchResults}
initialValues={{
@@ -149,9 +235,7 @@ export const AcquisitionPanel = (
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info">
Use this to perform a manual search.
</span>
<span className="help is-clearfix is-light is-info"></span>
</div>
);
}}
@@ -161,9 +245,9 @@ export const AcquisitionPanel = (
<button
type="submit"
className={
isAirDCPPSearchInProgress
false
? "button is-loading is-warning"
: "button"
: "button is-success is-light"
}
>
<span className="icon is-small">
@@ -182,57 +266,13 @@ export const AcquisitionPanel = (
<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>.
<code>Settings &gt; AirDC++ &gt; Connection</code>.
</div>
</article>
</div>
)}
</div>
{/* AirDC++ search instance details */}
{!isNil(searchInfo) && !isNil(searchInstance) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
({ value }) => (
<span className="tag is-warning" key={value}>
{value}
</span>
),
)}
</div>
</dt>
<dt>
Query:{" "}
<span className="has-text-weight-semibold">
{searchInfo.query.pattern}
</span>
</dt>
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
<dd>File type: {searchInfo.query.file_type}</dd>
</dl>
</div>
</div>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {searchInstance.id}</dt>
<dt>Owned by {searchInstance.owner}</dt>
<dd>Expires in: {searchInstance.expires_in}</dd>
</dl>
</div>
</div>
</div>
</div>
)}
{/* AirDC++ results */}
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
@@ -259,7 +299,7 @@ export const AcquisitionPanel = (
<p className="mb-2">
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
) : null}{" "}
) : null}
{ellipsize(result.name, 70)}
</p>
@@ -299,7 +339,8 @@ export const AcquisitionPanel = (
</div>
</td>
<td>
<a
<button
className="button is-small is-light is-success"
onClick={() =>
downloadDCPPResult(
searchInstance.id,
@@ -310,8 +351,11 @@ export const AcquisitionPanel = (
)
}
>
<i className="fas fa-file-download"></i>
</a>
<span className="icon">
<i className="fas fa-file-download"></i>
</span>
<span>Download </span>
</button>
</td>
</tr>
);
@@ -323,10 +367,22 @@ export const AcquisitionPanel = (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from <code>ADCS</code> hubs are
more reliable than <code>NMDCS</code> ones.
<p>
The default search term is an auto-detected title; you may
need to change it to get better matches if the auto-detected
one doesn't work.
</p>
</div>
</article>
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
<p className="content">
Searching via <strong>AirDC++</strong> is still in
<strong>alpha</strong>. Some searches may take arbitrarily
long, or may not work at all. Searches from <code>ADCS</code>
hubs are more reliable than <code>NMDCS</code> ones.
</p>
</div>
</article>
</div>
@@ -336,4 +392,4 @@ export const AcquisitionPanel = (
);
};
export default AcquisitionPanel;
export default AcquisitionPanel;

View File

@@ -1,6 +1,5 @@
import { filter, isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
@@ -8,7 +7,6 @@ import { refineQuery } from "filename-parser";
export const Menu = (props): ReactElement => {
const { data } = props;
const { setSlidingPanelContentId, setVisible } = props.handlers;
const dispatch = useDispatch();
const openDrawerWithCVMatches = useCallback(() => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
@@ -18,10 +16,10 @@ export const Menu = (props): ReactElement => {
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [dispatch, data]);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");

View File

@@ -1,11 +1,10 @@
import React, { useState, ReactElement, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import Card from "../Carda";
import Card from "../shared/Carda";
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
import { RawFileDetails } from "./RawFileDetails";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import { ComicVineSearchForm } from "./ComicVineSearchForm";
import TabControls from "./TabControls";
import { EditMetadataPanel } from "./EditMetadataPanel";
@@ -47,6 +46,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
rawFileDetails,
inferredMetadata,
sourcedMetadata: { comicvine, locg, comicInfo },
acquisition,
},
userSettings,
} = data;
@@ -55,34 +55,34 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const comicVineSearchQueryObject = useSelector(
(state: RootState) => state.comicInfo.searchQuery,
);
const comicVineAPICallProgress = useSelector(
(state: RootState) => state.comicInfo.inProgress,
);
const extractedComicBook = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.reading,
);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const dispatch = useDispatch();
// const dispatch = useDispatch();
const openModal = useCallback((filePath) => {
setIsOpen(true);
dispatch(
extractComicArchive(filePath, {
type: "full",
purpose: "reading",
imageResizeOptions: {
baseWidth: 1024,
},
}),
);
// dispatch(
// extractComicArchive(filePath, {
// type: "full",
// purpose: "reading",
// imageResizeOptions: {
// baseWidth: 1024,
// },
// }),
// );
}, []);
const afterOpenModal = useCallback((things) => {
@@ -100,7 +100,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
CVMatches: {
content: (props) => (
<>
<div className="card search-criteria-card">
{/* <div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
@@ -134,7 +134,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
/>
</div>
</div>
)}
)} */}
</>
),
},
@@ -193,13 +193,15 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
name: "Archive Operations",
content: <ArchiveOperations data={data.data} key={3} />,
content: <></>,
/*
<ArchiveOperations data={data.data} key={3} /> */
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: <i className="fa-solid fa-floppy-disk"></i>,
name: "Acquisition",
icon: <i className="fa-solid fa-circle-nodes"></i>,
name: "DC++ Search",
content: (
<AcquisitionPanel
query={airDCPPQuery}
@@ -213,6 +215,13 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
},
{
id: 5,
icon: <i className="fa-solid fa-droplet"></i>,
name: "Torrent Search",
content: <>Torrents</>,
shouldShow: true,
},
{
id: 6,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
@@ -278,7 +287,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
Read
</button>
<Modal
{/* <Modal
style={{ content: { marginTop: "2rem" } }}
isOpen={modalIsOpen}
onAfterOpen={afterOpenModal}
@@ -290,16 +299,21 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<ComicViewer
pages={extractedComicBook}
direction="ltr"
className={{closeButton: "border: 1px solid red;"}}
className={{
closeButton: "border: 1px solid red;",
}}
/>
)}
</Modal>
</Modal> */}
</>
)}
</div>
</div>
{<TabControls filteredTabs={filteredTabs} />}
<TabControls
filteredTabs={filteredTabs}
acquisition={acquisition}
/>
<SlidingPane
isOpen={visible}
@@ -317,4 +331,4 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
);
};
export default ComicDetail;
export default ComicDetail;

View File

@@ -1,22 +1,42 @@
import { isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useContext, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useParams } from "react-router-dom";
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
import { ComicDetail } from "../ComicDetail/ComicDetail";
import { useQuery } from "@tanstack/react-query";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
export const ComicDetailContainer = (): ReactElement | null => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const dispatch = useDispatch();
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const {
data: comicBookDetailData,
isLoading,
isError,
} = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
data: {
id: comicObjectId,
},
}),
});
console.log(comicBookDetailData);
useEffect(() => {
dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, [dispatch]);
return !isEmpty(comicBookDetailData) ? (
<ComicDetail data={comicBookDetailData} />
) : null;
}, []);
{
isError && <>Error</>;
}
{
isLoading && <>Loading...</>;
}
return (
comicBookDetailData?.data && <ComicDetail data={comicBookDetailData.data} />
);
};

View File

@@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs";
import { isUndefined } from "lodash";
import Card from "../Carda";
import Card from "../shared/Carda";
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (

View File

@@ -1,6 +1,6 @@
import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "../MatchResult";
import MatchResult from "./MatchResult";
import { isEmpty } from "lodash";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { fetchComicVineMatches } from "../actions/fileops.actions";
import { useDispatch } from "react-redux";
import { fetchComicVineMatches } from "../../actions/fileops.actions";
/**
* Component for performing search against ComicVine
@@ -14,7 +13,6 @@ import { useDispatch } from "react-redux";
* )
*/
export const ComicVineSearchForm = (data) => {
const dispatch = useDispatch();
const onSubmit = useCallback((value) => {
const userInititatedQuery = {
inferredIssueDetails: {
@@ -24,7 +22,7 @@ export const ComicVineSearchForm = (data) => {
year: value.issueYear,
},
};
dispatch(fetchComicVineMatches(data, userInititatedQuery));
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;

View File

@@ -1,12 +1,10 @@
import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
interface IDownloadsPanelProps {
data: any;
@@ -16,34 +14,33 @@ interface IDownloadsPanelProps {
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
const bundles = useSelector((state: RootState) => {
return state.airdcpp.bundles;
});
// AirDCPP Socket initialization
const userSettings = useSelector((state: RootState) => state.settings.data);
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
// const bundles = useSelector((state: RootState) => {
// return state.airdcpp.bundles;
// });
//
// // AirDCPP Socket initialization
// const userSettings = useSelector((state: RootState) => state.settings.data);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const dispatch = useDispatch();
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => {
try {
if (!isEmpty(userSettings)) {
dispatch(
getBundlesForComic(props.comicObjectId, socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
// dispatch(
// getBundlesForComic(props.comicObjectId, socket, {
// username: `${settings.directConnect.client.host.username}`,
// password: `${settings.directConnect.client.host.password}`,
// }),
// );
}
} catch (error) {
throw new Error(error);
}
}, [dispatch, airDCPPConfiguration]);
}, [airDCPPConfiguration]);
const Bundles = (props) => {
return !isEmpty(props.data) ? (

View File

@@ -1,5 +1,4 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Form, Field } from "react-final-form";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
@@ -9,7 +8,7 @@ import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
const onSubmit = async () => {};
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
@@ -28,10 +27,9 @@ export const EditMetadataPanel = (props): ReactElement => {
/>
);
};
const rawFileDetails = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
);
const dispatch = useDispatch();
// const rawFileDetails = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
// );
return (
<>

View File

@@ -1,7 +1,6 @@
import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../actions/comicinfo.actions";
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
@@ -15,12 +14,11 @@ const handleBrokenImage = (e) => {
};
export const MatchResult = (props: MatchResultProps) => {
const dispatch = useDispatch();
const applyCVMatch = useCallback(
(match, comicObjectId) => {
dispatch(applyComicVineMatch(match, comicObjectId));
},
[dispatch],
// (match, comicObjectId) => {
// dispatch(applyComicVineMatch(match, comicObjectId));
// },
[],
);
return (
<>

View File

@@ -1,18 +1,17 @@
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { useSelector } from "react-redux";
export const TabControls = (props): ReactElement => {
const comicBookDetailData = useSelector(
(state: RootState) => state.comicInfo.comicBookDetail,
);
const { filteredTabs } = props;
// const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, acquisition } = props;
const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
setActive(filteredTabs[0].id);
}, [comicBookDetailData]);
}, [acquisition]);
console.log(filteredTabs);
return (
<>
<div className="tabs">
@@ -25,12 +24,11 @@ export const TabControls = (props): ReactElement => {
>
{/* Downloads tab and count badge */}
<a>
{id === 5 &&
!isNil(comicBookDetailData.acquisition.directconnect) ? (
{id === 6 && !isNil(acquisition.directconnect) ? (
<span className="download-icon-labels">
<i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light">
{comicBookDetailData.acquisition.directconnect.downloads.length}
{acquisition.directconnect.downloads.length}
</span>
</span>
) : (

View File

@@ -1,6 +1,5 @@
import React, { ReactElement, useCallback, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { DnD } from "../../DnD";
import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane";
@@ -10,28 +9,27 @@ import { Canvas } from "../../shared/Canvas";
export const ArchiveOperations = (props): ReactElement => {
const { data } = props;
const isComicBookExtractionInProgress = useSelector(
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
);
const extractedComicBookArchive = useSelector(
(state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
);
// const isComicBookExtractionInProgress = useSelector(
// (state: RootState) => state.fileOps.comicBookExtractionInProgress,
// );
// const extractedComicBookArchive = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
// );
//
// const imageAnalysisResult = useSelector((state: RootState) => {
// return state.fileOps.imageAnalysisResults;
// });
const imageAnalysisResult = useSelector((state: RootState) => {
return state.fileOps.imageAnalysisResults;
});
const dispatch = useDispatch();
const unpackComicArchive = useCallback(() => {
dispatch(
extractComicArchive(data.rawFileDetails.filePath, {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
}),
);
// dispatch(
// extractComicArchive(data.rawFileDetails.filePath, {
// type: "full",
// purpose: "analysis",
// imageResizeOptions: {
// baseWidth: 275,
// },
// }),
// );
}, []);
// sliding panel config
@@ -64,7 +62,7 @@ export const ArchiveOperations = (props): ReactElement => {
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
dispatch(analyzeImage(imageFilePath));
// dispatch(analyzeImage(imageFilePath));
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
@@ -126,4 +124,4 @@ export const ArchiveOperations = (props): ReactElement => {
);
};
export default ArchiveOperations;
export default ArchiveOperations;

View File

@@ -48,7 +48,9 @@ export const ComicInfoXML = (data): ReactElement => {
<dd>
<span className="is-size-7">{json.notes[0]}</span>
</dd>
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
<dd className="mt-1 mb-1">
{!isUndefined(json.summary) && json.summary[0]}
</dd>
</dl>
</div>
);

View File

@@ -12,86 +12,56 @@ import {
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil } from "lodash";
import Header from "../shared/Header";
export const Dashboard = (): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchVolumeGroups());
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": false },
comicStatus: "recent",
}),
);
dispatch(
getComicBooks({
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
comicStatus: "wanted",
}),
);
dispatch(getLibraryStatistics());
}, []);
const recentComics = useSelector(
(state: RootState) => state.fileOps.recentComics
);
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const volumeGroups = useSelector(
(state: RootState) => state.fileOps.comicVolumeGroups,
);
const libraryStatistics = useSelector(
(state: RootState) => state.comicInfo.libraryStatistics,
);
// useEffect(() => {
// dispatch(fetchVolumeGroups());
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": false },
// comicStatus: "recent",
// }),
// );
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": true },
// comicStatus: "wanted",
// }),
// );
// dispatch(getLibraryStatistics());
// }, []);
//
// const recentComics = useSelector(
// (state: RootState) => state.fileOps.recentComics,
// );
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
// const volumeGroups = useSelector(
// (state: RootState) => state.fileOps.comicVolumeGroups,
// );
//
// const libraryStatistics = useSelector(
// (state: RootState) => state.comicInfo.libraryStatistics,
// );
return (
<div className="container">
<section className="section">
<h1 className="title">Dashboard</h1>
{!isEmpty(recentComics) ? (
<>
{/* Pull List */}
<PullList issues={recentComics} />
{/* Stats */}
{!isEmpty(libraryStatistics) && (
<LibraryStatistics stats={libraryStatistics} />
)}
{/* Wanted comics */}
{!isEmpty(wantedComics) && (
<WantedComicsList comics={wantedComics} />
)}
{/* Recent imports */}
<RecentlyImported comicBookCovers={recentComics} />
{/* Volumes */}
{!isEmpty(volumeGroups) && (
<VolumeGroups volumeGroups={volumeGroups} />
)}
</>
) : (
<ZeroState
header={"Set the source directory"}
message={
"No comics were found! Please point ThreeTwo! to a directory..."
}
/>
)}
</section>
</div>
);
};
export default Dashboard;
export default Dashboard;

View File

@@ -1,6 +1,7 @@
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import Card from "../Carda";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
@@ -20,7 +21,7 @@ export const PullList = ({ issues }: PullListProps): ReactElement => {
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2023-5-25",
startDate: "2023-9-9",
pageSize: "15",
currentPage: "1",
}),
@@ -88,12 +89,9 @@ export const PullList = ({ issues }: PullListProps): ReactElement => {
return (
<>
<div className="content">
<h4 className="title is-4">
<i className="fa-solid fa-splotch"></i> Discover
</h4>
<p className="subtitle is-7">
Pull List aggregated for the week from League Of Comic Geeks
</p>
<Header headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
iconClassNames="fa-solid fa-binoculars mr-2"/>
<div className="field is-grouped">
{/* select week */}
<div className="control">
@@ -127,7 +125,7 @@ export const PullList = ({ issues }: PullListProps): ReactElement => {
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({issue}, idx) => {
map(pullList, ({ issue }, idx) => {
return (
<Card
key={idx}
@@ -161,4 +159,4 @@ export const PullList = ({ issues }: PullListProps): ReactElement => {
);
};
export default PullList;
export default PullList;

View File

@@ -1,5 +1,5 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import Card from "../shared/Carda";
import { Link } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";

View File

@@ -1,5 +1,5 @@
import React, { ReactElement } from "react";
import Card from "../Carda";
import Card from "../shared/Carda";
import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";

View File

@@ -1,4 +1,10 @@
import React, { ReactElement, useCallback, useContext, useEffect, useState } from "react";
import React, {
ReactElement,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { useDispatch, useSelector } from "react-redux";
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
@@ -20,7 +26,9 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
const airDCPPTransfers = useSelector(
(state: RootState) => state.airdcpp.transfers,
);
const issueBundles = useSelector((state: RootState) => state.airdcpp.issue_bundles);
const issueBundles = useSelector(
(state: RootState) => state.airdcpp.issue_bundles,
);
const [bundles, setBundles] = useState([]);
// Make the call to get all transfers from AirDC++
useEffect(() => {
@@ -37,18 +45,26 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
useEffect(() => {
if (!isUndefined(issueBundles)) {
const foo = issueBundles.data.map((bundle) => {
const { rawFileDetails, inferredMetadata, acquisition: { directconnect: { downloads } }, sourcedMetadata: { locg, comicvine } } = bundle;
const {
rawFileDetails,
inferredMetadata,
acquisition: {
directconnect: { downloads },
},
sourcedMetadata: { locg, comicvine },
} = bundle;
const { issueName, url } = determineCoverFile({
rawFileDetails, comicvine, locg,
rawFileDetails,
comicvine,
locg,
});
return { ...bundle, issueName, url }
})
return { ...bundle, issueName, url };
});
setBundles(foo);
}
}, [issueBundles])
}, [issueBundles]);
return !isNil(bundles) ?
return !isNil(bundles) ? (
<div className="container">
<section className="section">
<h1 className="title">Downloads</h1>
@@ -56,45 +72,59 @@ export const Downloads = (props: IDownloadsProps): ReactElement => {
<div className="column is-half">
{bundles.map((bundle, idx) => {
console.log(bundle);
return <div key={idx}>
<MetadataPanel
data={bundle}
imageStyle={{ maxWidth: 80 }}
titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }}
containerStyle={{
maxWidth: 400,
padding: 0,
margin: "0 0 8px 0",
}} />
return (
<div key={idx}>
<MetadataPanel
data={bundle}
imageStyle={{ maxWidth: 80 }}
titleStyle={{ fontSize: "0.8rem" }}
tagsStyle={{ fontSize: "0.7rem" }}
containerStyle={{
maxWidth: 400,
padding: 0,
margin: "0 0 8px 0",
}}
/>
<table className="table is-size-7">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{bundle.acquisition.directconnect.downloads.map((bundle, idx) => {
return (<tr key={idx}>
<td>{bundle.name}</td>
<td>{bundle.size}</td>
<td>{bundle.type.str}</td>
<td><span className="tag is-warning">{bundle.bundleId}</span></td>
</tr>)
})}
</tbody>
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</div>
<table className="table is-size-7">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Bundle ID</th>
</tr>
</thead>
<tbody>
{bundle.acquisition.directconnect.downloads.map(
(bundle, idx) => {
return (
<tr key={idx}>
<td>{bundle.name}</td>
<td>{bundle.size}</td>
<td>{bundle.type.str}</td>
<td>
<span className="tag is-warning">
{bundle.bundleId}
</span>
</td>
</tr>
);
},
)}
</tbody>
</table>
{/* <pre>{JSON.stringify(bundle.acquisition.directconnect.downloads, null, 2)}</pre> */}
</div>
);
})}
</div>
</div>
</section>
</div> : <div>There are no downloads.</div>;
</div>
) : (
<div>There are no downloads.</div>
);
};
export default Downloads;

View File

@@ -1,7 +1,7 @@
import { debounce, isEmpty, map } from "lodash";
import React, { ReactElement, useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import Card from "../Carda";
import Card from "../shared/Carda";
import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel";

View File

@@ -1,133 +0,0 @@
import React, { ReactElement, useCallback, useContext, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
fetchComicBookMetadata,
toggleImportQueueStatus,
} from "../actions/fileops.actions";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import Loader from "react-loader-spinner";
interface IProps {
matches?: unknown;
fetchComicMetadata?: any;
path: string;
covers?: any;
}
/**
* Returns the average of two numbers.
*
* @remarks
* This method is part of the {@link core-library#Statistics | Statistics subsystem}.
*
* @param x - The first input number
* @param y - The second input number
* @returns The arithmetic mean of `x` and `y`
*
* @beta
*/
export const Import = (props: IProps): ReactElement => {
const dispatch = useDispatch();
const libraryQueueResults = useSelector(
(state: RootState) => state.fileOps.librarySearchResultCount,
);
const libraryQueueImportStatus = useSelector(
(state: RootState) => state.fileOps.IMSCallInProgress,
);
const [isImportQueuePaused, setImportQueueStatus] = useState(false);
const initiateImport = useCallback(() => {
if (typeof props.path !== "undefined") {
dispatch(fetchComicBookMetadata(props.path));
}
}, [dispatch]);
const toggleImport = useCallback(() => {
setImportQueueStatus(!isImportQueuePaused);
if (isImportQueuePaused === false) {
dispatch(toggleImportQueueStatus({ action: "resume" }));
} else if (isImportQueuePaused === true) {
dispatch(toggleImportQueueStatus({ action: "pause" }));
}
}, [isImportQueuePaused]);
const pauseIconText = (
<>
<i className="fa-solid fa-pause mr-2"></i> Pause Import
</>
);
const playIconText = (
<>
<i className="fa-solid fa-play mr-2"></i> Resume Import
</>
);
return (
<div className="container">
<section className="section is-small">
<h1 className="title">Import</h1>
<article className="message is-dark">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Import Only
</span>
will add comics identified from the mapped folder into the local
db.
</p>
<p>
<span className="tag is-medium is-info is-light">
Import and Tag
</span>
will scan the ComicVine, shortboxed APIs and import comics from
the mapped folder with the additional metadata.
</p>
</div>
</article>
<p className="buttons">
<button
className={
libraryQueueImportStatus
? "button is-loading is-medium"
: "button is-medium"
}
onClick={initiateImport}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Import Only</span>
</button>
<button className="button is-medium">
<span className="icon">
<i className="fas fa-tag"></i>
</span>
<span>Import and Tag</span>
</button>
</p>
<div className="columns is-multiline">
<div className="column is-one-fifth">
<div className="box control-palette">
<span className="is-size-2 has-text-weight-bold">
{JSON.stringify(libraryQueueResults, null, 2)}
</span>
</div>
<div className="is-half">
<div className="content">
<div className="control">
<button
className="button is-warning is-light"
onClick={toggleImport}
>
{!isImportQueuePaused ? pauseIconText : playIconText}
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
};
export default Import;

View File

@@ -0,0 +1,276 @@
import React, { ReactElement, useCallback, useEffect } from "react";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { format } from "date-fns";
import Loader from "react-loader-spinner";
import { isEmpty, isNil, isUndefined } from "lodash";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import axios from "axios";
interface IProps {
matches?: unknown;
fetchComicMetadata?: any;
path: string;
covers?: any;
}
/**
* Component to facilitate the import of comics to the ThreeTwo library
*
* @param x - The first input number
* @param y - The second input number
* @returns The arithmetic mean of `x` and `y`
*
* @beta
*/
export const Import = (props: IProps): ReactElement => {
const queryClient = useQueryClient();
const { importJobQueue, socketIOInstance } = useStore(
useShallow((state) => ({
importJobQueue: state.importJobQueue,
socketIOInstance: state.socketIOInstance,
})),
);
const sessionId = localStorage.getItem("sessionId");
const { mutate: initiateImport } = useMutation({
mutationFn: async () =>
await axios.request({
url: `http://localhost:3000/api/library/newImport`,
method: "POST",
data: { sessionId },
}),
});
const { data, isError, isLoading } = useQuery({
queryKey: ["allImportJobResults"],
queryFn: async () =>
await axios({
method: "GET",
url: "http://localhost:3000/api/jobqueue/getJobResultStatistics",
}),
});
// 1a. Act on each comic issue successfully imported/failed, as indicated
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
const { completedJobCount, importResult } = data;
importJobQueue.setJobCount("successful", completedJobCount);
importJobQueue.setMostRecentImport(importResult.rawFileDetails.name);
});
socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
const { failedJobCount } = data;
importJobQueue.setJobCount("failed", failedJobCount);
});
// 1b. Clear the localStorage sessionId upon receiving the
// LS_IMPORT_QUEUE_DRAINED event
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
localStorage.removeItem("sessionId");
importJobQueue.setStatus("drained");
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
const toggleQueue = (queueAction: string, queueStatus: string) => {
socketIOInstance.emit(
"call",
"socket.setQueueStatus",
{
queueAction,
queueStatus,
},
(data) => console.log(data),
);
};
/**
* Method to render import job queue pause/resume controls on the UI
*
* @param status The `string` status (either `"pause"` or `"resume"`)
* @returns ReactElement A `<button/>` that toggles queue status
* @remarks Sets the global `importJobQueue.status` state upon toggling
*/
const renderQueueControls = (status: string): ReactElement | null => {
switch (status) {
case "running":
return (
<div className="control">
<button
className="button is-warning is-light"
onClick={() => {
toggleQueue("pause", "paused");
importJobQueue.setStatus("paused");
}}
>
<i className="fa-solid fa-pause mr-2"></i> Pause
</button>
</div>
);
case "paused":
return (
<div className="control">
<button
className="button is-success is-light"
onClick={() => {
toggleQueue("resume", "running");
importJobQueue.setStatus("running");
}}
>
<i className="fa-solid fa-play mr-2"></i> Resume
</button>
</div>
);
case "drained":
return null;
default:
return null;
}
};
return (
<div className="container">
<section className="section is-small">
<h1 className="title">Import Comics</h1>
<article className="message is-dark">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Import Comics
</span>
will add comics identified from the mapped folder into ThreeTwo's
database.
</p>
<p>
Metadata from ComicInfo.xml, if present, will also be extracted.
</p>
<p>
This process could take a while, if you have a lot of comics, or
are importing over a network connection.
</p>
</div>
</article>
<p className="buttons">
<button
className={
importJobQueue.status === "drained" ||
importJobQueue.status === undefined
? "button is-medium"
: "button is-loading is-medium"
}
onClick={() => {
initiateImport();
importJobQueue.setStatus("running");
}}
>
<span className="icon">
<i className="fas fa-file-import"></i>
</span>
<span>Start Import</span>
</button>
</p>
{importJobQueue.status !== "drained" &&
!isUndefined(importJobQueue.status) && (
<>
<table className="table">
<thead>
<tr>
<th>Completed Jobs</th>
<th>Failed Jobs</th>
<th>Queue Controls</th>
<th>Queue Status</th>
</tr>
</thead>
<tbody>
<tr>
<th>
{importJobQueue.successfulJobCount > 0 && (
<div className="box has-background-success-light has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.successfulJobCount}
</span>
</div>
)}
</th>
<td>
{importJobQueue.failedJobCount > 0 && (
<div className="box has-background-danger has-text-centered">
<span className="is-size-2 has-text-weight-bold">
{importJobQueue.failedJobCount}
</span>
</div>
)}
</td>
<td>{renderQueueControls(importJobQueue.status)}</td>
<td>
{importJobQueue.status !== undefined ? (
<span className="tag is-warning">
{importJobQueue.status}
</span>
) : null}
</td>
</tr>
</tbody>
</table>
Imported{" "}
<span className="has-text-weight-bold">
{importJobQueue.mostRecentImport}
</span>
</>
)}
{/* Past imports */}
{!isLoading && !isEmpty(data?.data) && (
<>
<h3 className="subtitle is-4 mt-5">Past Imports</h3>
<table className="table">
<thead>
<tr>
<th>Time Started</th>
<th>Session Id</th>
<th>Imported</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{data?.data.map((jobResult, id) => {
return (
<tr key={id}>
<td>
{format(
new Date(jobResult.earliestTimestamp),
"EEEE, hh:mma, do LLLL Y",
)}
</td>
<td>
<span className="tag is-warning">
{jobResult.sessionId}
</span>
</td>
<td>
<span className="tag is-success">
{jobResult.completedJobs}
</span>
</td>
<td>
<span className="tag is-danger">
{jobResult.failedJobs}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</>
)}
</section>
</div>
);
};
export default Import;

View File

@@ -1,12 +1,12 @@
import React, { useMemo, ReactElement, useCallback, useEffect } from "react";
import React, { useMemo, ReactElement, useState } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import axios from "axios";
/**
* Component that tabulates the contents of the user's ThreeTwo Library.
@@ -16,30 +16,36 @@ import ellipsize from "ellipsize";
* <Library />
*/
export const Library = (): ReactElement => {
const searchResults = useSelector(
(state: RootState) => state.fileOps.libraryComics,
);
const searchError = useSelector((state: RootState) => state.fileOps.librarySearchError);
const dispatch = useDispatch();
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 15,
from: 0,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
// programatically navigate to comic detail
// Method to fetch paginated issues
const fetchIssues = async (searchQuery, offset, type) => {
let pagination = {
size: 15,
from: offset,
};
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
searchQuery,
pagination,
type,
},
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset],
queryFn: () => fetchIssues({}, offset, "all"),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
navigate(`/comic/details/${row.original._id}`);
@@ -47,7 +53,7 @@ export const Library = (): ReactElement => {
const ComicInfoXML = (value) => {
return value.data ? (
<div className="comicvine-metadata">
<div className="comicvine-metadata mt-3">
<dl>
<span className="tags has-addons is-size-7">
<span className="tag">Series</span>
@@ -156,23 +162,11 @@ export const Library = (): ReactElement => {
* @returns void
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
setOffset(pageSize * pageIndex + 1);
}
};
/**
* Pagination control that fetches the previous x (pageSize) items
@@ -181,29 +175,15 @@ export const Library = (): ReactElement => {
* @param {number} pageSize
* @returns void
**/
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
const previousPage = (pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17;
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2);
} else {
from = (pageIndex - 1) * pageSize + 2 - 16;
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from,
},
type: "all",
trigger: "libraryPage",
},
),
);
}, []);
setOffset(from);
};
// ImportStatus.propTypes = {
// value: PropTypes.bool.isRequired,
@@ -214,13 +194,13 @@ export const Library = (): ReactElement => {
<div className="header-area">
<h1 className="title">Library</h1>
</div>
{!isEmpty(searchResults) ? (
{!isUndefined(searchResults?.hits) ? (
<div>
<div className="library">
<T2Table
totalPages={searchResults.total.value}
totalPages={searchResults.hits.total.value}
columns={columns}
sourceData={searchResults?.hits}
sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail}
paginationHandlers={{
nextPage,
@@ -239,14 +219,15 @@ export const Library = (): ReactElement => {
back.
</div>
</article>
<pre>
{!isUndefined(searchError.data) &&
JSON.stringify(
searchError.data.meta.body.error.root_cause,
{!isUndefined(searchResults?.data?.meta?.body) ? (
<pre>
{JSON.stringify(
searchResults.data.meta.body.error.root_cause,
null,
4,
)}
</pre>
</pre>
) : null}
</div>
</div>
)}

View File

@@ -12,7 +12,7 @@ import { useDispatch, useSelector } from "react-redux";
import { getComicBooks } from "../../actions/fileops.actions";
import { isNil, isEmpty, isUndefined } from "lodash";
import Masonry from "react-masonry-css";
import Card from "../Carda";
import Card from "../shared/Carda";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";

View File

@@ -2,29 +2,27 @@ import React, { ReactElement, useCallback } from "react";
import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (): ReactElement => {
const dispatch = useDispatch();
const handleSubmit = useCallback((e) => {
dispatch(
searchIssue(
{
query: {
volumeName: e.search,
},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
},
),
);
// dispatch(
// searchIssue(
// {
// query: {
// volumeName: e.search,
// },
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumeName",
// trigger: "libraryPage",
// },
// ),
// );
}, []);
return (
<div className="box">
@@ -56,7 +54,6 @@ export const SearchBar = (): ReactElement => {
</form>
)}
/>
</div>
);
};

View File

@@ -1,25 +1,23 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import T2Table from "../shared/T2Table";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { useDispatch, useSelector } from "react-redux";
import Card from "../Carda";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { isNil } from "lodash";
export const PullList = (): ReactElement => {
const pullListComics = useSelector(
(state: RootState) => state.comicInfo.pullList,
);
// const pullListComics = useSelector(
// (state: RootState) => state.comicInfo.pullList,
// );
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2022-11-15",
pageSize: "15",
currentPage: "1",
}),
);
// dispatch(
// getWeeklyPullList({
// startDate: "2023-7-28",
// pageSize: "15",
// currentPage: "1",
// }),
// );
}, []);
const nextPageHandler = () => {};
const previousPageHandler = () => {};
@@ -109,15 +107,15 @@ export const PullList = (): ReactElement => {
{!isNil(pullListComics) && (
<div>
<div className="library">
<T2Table
sourceData={pullListComics}
columns={columnData}
totalPages={pullListComics.length}
paginationHandlers={{
nextPage: nextPageHandler,
previousPage: previousPageHandler,
}}
/>
<T2Table
sourceData={pullListComics}
columns={columnData}
totalPages={pullListComics.length}
paginationHandlers={{
nextPage: nextPageHandler,
previousPage: previousPageHandler,
}}
/>
</div>
</div>
)}

View File

@@ -1,12 +1,12 @@
import React, { useCallback, ReactElement } from "react";
import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { importToDB } from "../actions/fileops.actions";
import { importToDB } from "../../actions/fileops.actions";
import { useSelector, useDispatch } from "react-redux";
import { comicinfoAPICall } from "../actions/comicinfo.actions";
import { search } from "../services/api/SearchApi";
import { comicinfoAPICall } from "../../actions/comicinfo.actions";
import { search } from "../../services/api/SearchApi";
import { Form, Field } from "react-final-form";
import Card from "./Carda";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import dayjs from "dayjs";

View File

@@ -0,0 +1,25 @@
import React, { ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";
import { getServiceStatus } from "../../actions/fileops.actions";
export const ServiceStatuses = (): ReactElement => {
const serviceStatus = useSelector(
(state: RootState) => state.fileOps.libraryServiceStatus,
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getServiceStatus());
}, []);
return (
<div className="is-clearfix">
<div className="mt-4">
<h3 className="title">Core Services</h3>
<h6 className="subtitle has-text-grey-light">
Statuses for core services
</h6>
</div>
<pre>{JSON.stringify(serviceStatus, null, 2)}</pre>
</div>
);
};

View File

@@ -0,0 +1,142 @@
import React, { ReactElement, useEffect, useState, useContext } from "react";
import { Form, Field } from "react-final-form";
import { isEmpty, isNil, isUndefined } from "lodash";
import Select from "react-select";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useStore } from "../../../store";
import axios from "axios";
export const AirDCPPHubsForm = (): ReactElement => {
const queryClient = useQueryClient();
const {
airDCPPSocketInstance,
airDCPPClientConfiguration,
airDCPPSessionInformation,
} = useStore((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
}));
const {
data: settings,
isLoading,
isError,
} = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
/**
* Get the hubs list from an AirDCPP Socket
*/
const { data: hubs } = useQuery({
queryKey: [],
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
enabled: !!settings,
});
let hubList = {};
if (!isEmpty(hubs)) {
console.log("hs", hubs);
hubList = hubs.map(({ hub_url, identity }) => ({
value: hub_url,
label: identity.name,
}));
}
console.log(hubList);
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: {
settingsPayload: values,
settingsObjectId: settings?.data._id,
settingsKey: "directConnect",
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
const validate = async () => {};
const SelectAdapter = ({ input, ...rest }) => {
return <Select {...input} {...rest} isClearable isMulti />;
};
return (
<>
{!isEmpty(hubList) && !isUndefined(hubs) ? (
<Form
onSubmit={mutate}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<div>
<h3 className="title">Hubs</h3>
<h6 className="subtitle has-text-grey-light">
Select the hubs you want to perform searches against.
</h6>
</div>
<div className="field">
<label className="label">AirDC++ Host</label>
<div className="control">
<Field
name="hubs"
component={SelectAdapter}
className="basic-multi-select"
placeholder="Select Hubs to Search Against"
options={hubList}
/>
</div>
</div>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
)}
/>
) : (
<>
<article className="message">
<div className="message-body">
No configured hubs detected in AirDC++. <br />
Configure to a hub in AirDC++ and then select a default hub here.
</div>
</article>
</>
)}
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
<>
<div className="mt-4">
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
Your selection in the dropdown <strong>will replace</strong> the
existing selection.
</div>
</article>
</div>
<div className="box mt-3">
<h6>Default Hub For Searches:</h6>
{settings?.data.directConnect?.client.hubs.map(
({ value, label }) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>
</div>
),
)}
</div>
</>
) : null}
</>
);
};
export default AirDCPPHubsForm;

View File

@@ -2,7 +2,6 @@ import React, { ReactElement } from "react";
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
const { settings } = settingsObject;
console.log(settings);
return (
<div className="mt-4 is-clearfix">
<div className="card">

View File

@@ -0,0 +1,102 @@
import React, { ReactElement, useCallback } from "react";
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
import { isUndefined, isEmpty } from "lodash";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
import { useShallow } from "zustand/react/shallow";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
export const AirDCPPSettingsForm = (): ReactElement => {
// cherry-picking selectors for:
// 1. initial values for the form
// 2. If initial values are present, get the socket information to display
const { setState } = useStore;
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPClientConfiguration,
airDCPPSocketInstance,
setAirDCPPSocketInstance,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPSocketInstance: state.airDCPPSocketInstance,
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
})),
);
/**
* Mutation to update settings and subsequently initialize
* AirDC++ socket with those settings
*/
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "directConnect" },
}),
onSuccess: async (values) => {
const {
data: {
directConnect: {
client: { host },
},
},
} = values;
const dcppSocketInstance = await initializeAirDCPPSocket(host);
setState({
airDCPPClientConfiguration: host,
airDCPPSocketInstance: dcppSocketInstance,
});
},
});
const deleteSettingsMutation = useMutation(
async () =>
await axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
// const removeSettings = useCallback(async () => {
// // airDCPPSettings.setSettings({});
// }, []);
//
const initFormData = !isUndefined(airDCPPClientConfiguration)
? airDCPPClientConfiguration
: {};
console.log(airDCPPClientConfiguration);
return (
<>
<ConnectionForm
initialData={initFormData}
submitHandler={mutate}
formHeading={"Configure AirDC++"}
/>
{!isEmpty(airDCPPSessionInformation) ? (
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
) : null}
{!isEmpty(airDCPPClientConfiguration) ? (
<p className="control mt-4">
as
<button
className="button is-danger"
onClick={() => deleteSettingsMutation.mutate()}
>
Delete
</button>
</p>
) : null}
</>
);
};
export default AirDCPPSettingsForm;

View File

@@ -0,0 +1,72 @@
import React, { ReactElement } from "react";
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useQuery, useMutation } from "@tanstack/react-query";
import axios from "axios";
export const QbittorrentConnectionForm = (): ReactElement => {
// fetch settings
const { data, isLoading, isError } = useQuery({
queryKey: ["settings"],
queryFn: async () =>
await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
}),
});
const hostDetails = data?.data.bittorrent.client.host;
// connect to qbittorrent client
const { data: connectionDetails } = useQuery({
queryKey: [],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/connect",
method: "POST",
data: hostDetails,
}),
enabled: !!hostDetails,
});
// get qbittorrent client info
const { data: qbittorrentClientInfo } = useQuery({
queryKey: ["qbittorrentClientInfo"],
queryFn: async () =>
await axios({
url: "http://localhost:3060/api/qbittorrent/getClientInfo",
method: "GET",
}),
enabled: !!connectionDetails,
});
console.log(qbittorrentClientInfo?.data);
// Update action using a mutation
const { mutate } = useMutation({
mutationFn: async (values) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
data: { settingsPayload: values, settingsKey: "bittorrent" },
}),
});
if (isError)
return (
<>
<pre>Something went wrong connecting to qBittorrent.</pre>
</>
);
if (!isLoading) {
return (
<>
<ConnectionForm
initialData={hostDetails}
formHeading={"qBittorrent Configuration"}
submitHandler={mutate}
/>
<pre className="mt-5">
{JSON.stringify(qbittorrentClientInfo?.data, null, 4)}
</pre>
</>
);
}
};
export default QbittorrentConnectionForm;

View File

@@ -1,8 +1,10 @@
import React, { useState, ReactElement } from "react";
import { AirDCPPSettingsForm } from "./AirDCPPSettings/AirDCPPSettingsForm";
import { AirDCPPHubsForm } from "./AirDCPPSettings/AirDCPPHubsForm";
import { QbittorrentConnectionForm } from "./QbittorrentSettings/QbittorrentConnectionForm";
import { SystemSettingsForm } from "./SystemSettings/SystemSettingsForm";
import settingsObject from "../constants/settings/settingsMenu.json";
import { ServiceStatuses } from "../ServiceStatuses/ServiceStatuses";
import settingsObject from "../../constants/settings/settingsMenu.json";
import { isUndefined, map } from "lodash";
interface ISettingsProps {}
@@ -12,7 +14,11 @@ export const Settings = (props: ISettingsProps): ReactElement => {
const settingsContent = [
{
id: "adc-hubs",
content: <div key="adc-hubs">{<AirDCPPHubsForm />}</div>,
content: (
<div key="adc-hubs">
<AirDCPPHubsForm />
</div>
),
},
{
id: "adc-connection",
@@ -22,6 +28,18 @@ export const Settings = (props: ISettingsProps): ReactElement => {
</div>
),
},
{
id: "qbt-connection",
content: (
<div key="qbt-connection">
<QbittorrentConnectionForm />
</div>
),
},
{
id: "core-service",
content: <>a</>,
},
{
id: "flushdb",
content: (

View File

@@ -1,15 +1,16 @@
import React, { ReactElement, useCallback } from "react";
import { flushDb } from "../../actions/settings.actions";
import { useDispatch, useSelector } from "react-redux";
import React, { ReactElement } from "react";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
export const SystemSettingsForm = (): ReactElement => {
const dispatch = useDispatch();
const isSettingsCallInProgress = useSelector(
(state: RootState) => state.settings.inProgress,
);
const flushDatabase = useCallback(() => {
dispatch(flushDb());
}, []);
const { mutate: flushDb, isLoading } = useMutation({
mutationFn: async () => {
await axios({
url: `http://localhost:3000/api/library/flushDb`,
method: "POST",
});
},
});
return (
<div className="is-clearfix">
@@ -48,11 +49,9 @@ export const SystemSettingsForm = (): ReactElement => {
<button
className={
isSettingsCallInProgress
? "button is-danger is-loading"
: "button is-danger"
isLoading ? "button is-danger is-loading" : "button is-danger"
}
onClick={flushDatabase}
onClick={() => flushDb()}
>
<span className="icon">
<i className="fas fa-eraser"></i>

View File

@@ -2,7 +2,7 @@ import { isArray, map } from "lodash";
import React, { useEffect, ReactElement } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getComicBooksDetailsByIds } from "../../actions/comicinfo.actions";
import { Card } from "../Carda";
import { Card } from "../shared/Carda";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";

View File

@@ -9,7 +9,7 @@ import {
} from "../../actions/comicinfo.actions";
import PotentialLibraryMatches from "./PotentialLibraryMatches";
import Masonry from "react-masonry-css";
import { Card } from "../Carda";
import { Card } from "../shared/Carda";
import SlidingPane from "react-sliding-pane";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";

View File

@@ -1,31 +1,29 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import Card from "../Carda";
import Card from "../shared/Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { isUndefined } from "lodash";
export const Volumes = (props): ReactElement => {
const volumes = useSelector((state: RootState) => state.fileOps.volumes);
const dispatch = useDispatch();
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 25,
from: 0,
},
type: "volumes",
trigger: "volumesPage",
},
),
);
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumes",
// trigger: "volumesPage",
// },
// ),
// );
}, []);
const columnData = useMemo(
() => [

View File

@@ -1,5 +1,4 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table";
@@ -7,26 +6,25 @@ import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
export const WantedComics = (props): ReactElement => {
const wantedComics = useSelector(
(state: RootState) => state.fileOps.wantedComics,
);
const dispatch = useDispatch();
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
useEffect(() => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: 25,
from: 0,
},
type: "wanted",
trigger: "wantedComicsPage"
},
),
);
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "wanted",
// trigger: "wantedComicsPage"
// },
// ),
// );
}, []);
const columnData = [
@@ -37,7 +35,7 @@ export const WantedComics = (props): ReactElement => {
header: "Details",
id: "comicDetails",
minWidth: 350,
accessorFn: data => data,
accessorFn: (data) => data,
cell: (value) => <MetadataPanel data={value.getValue()} />,
},
],
@@ -49,8 +47,10 @@ export const WantedComics = (props): ReactElement => {
header: "Files",
accessorKey: "acquisition",
align: "right",
cell: props => {
const { directconnect: { downloads } } = props.getValue();
cell: (props) => {
const {
directconnect: { downloads },
} = props.getValue();
return (
<div
style={{
@@ -60,9 +60,7 @@ export const WantedComics = (props): ReactElement => {
}}
>
{downloads.length > 0 ? (
<span className="tag is-warning">
{downloads.length}
</span>
<span className="tag is-warning">{downloads.length}</span>
) : null}
</div>
);
@@ -72,11 +70,17 @@ export const WantedComics = (props): ReactElement => {
header: "Download Details",
id: "downloadDetails",
accessorKey: "acquisition",
cell: data => <ol>
{data.getValue().directconnect.downloads.map((download, idx) => {
return <li className="is-size-7" key={idx}>{download.name}</li>;
})}
</ol>
cell: (data) => (
<ol>
{data.getValue().directconnect.downloads.map((download, idx) => {
return (
<li className="is-size-7" key={idx}>
{download.name}
</li>
);
})}
</ol>
),
},
{
header: "Type",
@@ -92,7 +96,7 @@ export const WantedComics = (props): ReactElement => {
* @param {number} pageIndex
* @param {number} pageSize
* @returns void
*
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
@@ -112,7 +116,6 @@ export const WantedComics = (props): ReactElement => {
);
}, []);
/**
* Pagination control that fetches the previous x (pageSize) items
* based on the y (pageIndex) offset from the Elasticsearch index
@@ -138,7 +141,7 @@ export const WantedComics = (props): ReactElement => {
from,
},
type: "wanted",
trigger: "wantedComicsPage"
trigger: "wantedComicsPage",
},
),
);
@@ -161,7 +164,7 @@ export const WantedComics = (props): ReactElement => {
nextPage: nextPage,
previousPage: previousPage,
}}
// rowClickHandler={navigateToComicDetail}
// rowClickHandler={navigateToComicDetail}
/>
{/* pagination controls */}
</div>

View File

@@ -15,7 +15,7 @@ interface ICardProps {
imageStyle?: PropTypes.object;
}
const renderCard = (props): ReactElement => {
const renderCard = (props: ICardProps): ReactElement => {
switch (props.orientation) {
case "horizontal":
return (
@@ -85,8 +85,8 @@ const renderCard = (props): ReactElement => {
}
};
export const Card = (props: ICardProps): ReactElement => {
return renderCard(props);
};
export const Card = React.memo(
(props: ICardProps): ReactElement => renderCard(props),
);
export default Card;

View File

@@ -0,0 +1,108 @@
import React, { ReactElement } from "react";
import { Form, Field } from "react-final-form";
import { hostNameValidator } from "../../../shared/utils/validator.utils";
import { isEmpty } from "lodash";
export const ConnectionForm = ({
initialData,
submitHandler,
formHeading,
}): ReactElement => {
return (
<>
<Form
onSubmit={submitHandler}
initialValues={initialData}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<h2>{formHeading}</h2>
<label className="label">Hostname</label>
<div className="field has-addons">
<p className="control">
<span className="select">
<Field name="protocol" component="select">
<option>Protocol</option>
<option value="http">http://</option>
<option value="https">https://</option>
</Field>
</span>
</p>
<div className="control is-expanded">
<Field name="hostname" validate={hostNameValidator}>
{({ input, meta }) => (
<div>
<input
{...input}
type="text"
placeholder="hostname"
className="input"
/>
{meta.error && meta.touched && (
<span className="is-size-7 has-text-danger">
{meta.error}
</span>
)}
</div>
)}
</Field>
</div>
<p className="control">
<Field
name="port"
component="input"
className="input"
placeholder="port"
/>
</p>
</div>
<div className="field">
<label className="label">Credentials</label>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="username"
component="input"
className="input"
placeholder="Username"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
<div className="field">
<p className="control is-expanded has-icons-left has-icons-right">
<Field
name="password"
component="input"
type="password"
className="input"
placeholder="Password"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-lock"></i>
</span>
</p>
</div>
</div>
</div>
<div className="field is-grouped">
<p className="control">
<button type="submit" className="button is-primary">
{!isEmpty(initialData) ? "Update" : "Save"}
</button>
</p>
<p className="control">
<button type="submit" className="button is-danger">
{!isEmpty(initialData) && "Delete"}
</button>
</p>
</div>
</form>
)}
/>
</>
);
};

View File

@@ -0,0 +1,5 @@
import React from "react";
export const ErrorPage = () => {
return <>Error has been encountered.</>;
};

View File

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

View File

@@ -2,7 +2,7 @@ import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import ellipsize from "ellipsize";
import prettyBytes from "pretty-bytes";
import { Card } from "../Carda";
import { Card } from "../shared/Carda";
import { convert } from "html-to-text";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import { find, isUndefined } from "lodash";

View File

@@ -1,29 +1,46 @@
import React, { useContext } from "react";
import { SearchBar } from "./GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "./ComicDetail/DownloadProgressTick";
import React from "react";
import { SearchBar } from "../GlobalSearchBar/SearchBar";
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
import { Link } from "react-router-dom";
import { useSelector } from "react-redux";
import { isUndefined } from "lodash";
import { isEmpty, isNil, isUndefined } from "lodash";
import { format, fromUnixTime } from "date-fns";
import { useStore } from "../../store/index";
import { useShallow } from "zustand/react/shallow";
const Navbar: React.FunctionComponent = (props) => {
const downloadProgressTick = useSelector(
(state: RootState) => state.airdcpp.downloadProgressData,
const {
airDCPPSocketConnected,
airDCPPDisconnectionInfo,
airDCPPSessionInformation,
airDCPPDownloadTick,
importJobQueue,
} = useStore(
useShallow((state) => ({
airDCPPSocketConnected: state.airDCPPSocketConnected,
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
airDCPPSessionInformation: state.airDCPPSessionInformation,
airDCPPDownloadTick: state.airDCPPDownloadTick,
importJobQueue: state.importJobQueue,
})),
);
// const downloadProgressTick = useSelector(
// (state: RootState) => state.airdcpp.downloadProgressData,
// );
//
// const airDCPPSocketConnectionStatus = useSelector(
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
// );
// const airDCPPSessionInfo = useSelector(
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
// );
// const socketDisconnectionReason = useSelector(
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
// );
const airDCPPSocketConnectionStatus = useSelector(
(state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
);
const airDCPPSessionInfo = useSelector(
(state: RootState) => state.airdcpp.airDCPPSessionInfo,
);
const socketDisconnectionReason = useSelector(
(state: RootState) => state.airdcpp.socketDisconnectionReason,
);
return (
<nav className="navbar is-fixed-top">
<div className="navbar-brand">
<Link to="/" className="navbar-item">
<Link to="/dashboard" className="navbar-item">
<img
src="/src/client/assets/img/threetwo.svg"
alt="ThreeTwo! A comic book curator"
@@ -69,7 +86,7 @@ const Navbar: React.FunctionComponent = (props) => {
Downloads
</Link>
<SearchBar />
{/* <SearchBar /> */}
<Link to="/search" className="navbar-item">
Search ComicVine
@@ -82,18 +99,54 @@ const Navbar: React.FunctionComponent = (props) => {
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-download"></i>
{downloadProgressTick && <div className="pulsating-circle"></div>}
{!isEmpty(airDCPPDownloadTick) && (
<div className="pulsating-circle"></div>
)}
</a>
{!isUndefined(downloadProgressTick) ? (
{!isEmpty(airDCPPDownloadTick) ? (
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<DownloadProgressTick data={downloadProgressTick} />
</a> </div>
<DownloadProgressTick data={airDCPPDownloadTick} />
</a>
</div>
) : null}
</div>
{!isUndefined(importJobQueue.status) &&
location.hash !== "#/import" ? (
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link is-arrowless">
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
</a>
<div className="navbar-dropdown is-right is-boxed">
<a className="navbar-item">
<ul>
{importJobQueue.successfulJobCount > 0 ? (
<li className="mb-2">
<span className="tag is-success mr-2">
{importJobQueue.successfulJobCount}
</span>
imported.
</li>
) : null}
{importJobQueue.failedJobCount > 0 ? (
<li>
<span className="tag is-danger mr-2">
{importJobQueue.failedJobCount}
</span>
failed to import.
</li>
) : null}
</ul>
</a>
</div>
</div>
) : null}
{/* AirDC++ socket connection status */}
<div className="navbar-item has-dropdown is-hoverable">
{airDCPPSocketConnectionStatus ? (
{!isUndefined(airDCPPSessionInformation.user) ? (
<>
<a className="navbar-link is-arrowless has-text-success">
<i className="fa-solid fa-bolt"></i>
@@ -105,7 +158,9 @@ const Navbar: React.FunctionComponent = (props) => {
Last login was{" "}
<span className="tag">
{format(
fromUnixTime(airDCPPSessionInfo.user.last_login),
fromUnixTime(
airDCPPSessionInformation?.user.last_login,
),
"dd MMMM, yyyy",
)}
</span>
@@ -113,19 +168,17 @@ const Navbar: React.FunctionComponent = (props) => {
<hr className="navbar-divider" />
<p>
<span className="tag has-text-success">
{airDCPPSessionInfo.user.username}
{airDCPPSessionInformation.user.username}
</span>
connected to{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.system_info.client_version}
{airDCPPSessionInformation.system_info.client_version}
</span>{" "}
with session ID{" "}
<span className="tag has-text-success">
{airDCPPSessionInfo.session_id}
{airDCPPSessionInformation.session_id}
</span>
</p>
{/* <pre>{JSON.stringify(airDCPPSessionInfo, null, 2)}</pre> */}
</div>
</>
) : (
@@ -134,9 +187,7 @@ const Navbar: React.FunctionComponent = (props) => {
<i className="fa-solid fa-bolt"></i>
</a>
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
<pre>
{JSON.stringify(socketDisconnectionReason, null, 2)}
</pre>
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
</div>
</>
)}

View File

@@ -12,26 +12,30 @@ import {
} from "@tanstack/react-table";
export const T2Table = (tableOptions): ReactElement => {
const { sourceData, columns, paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler } =
tableOptions;
const {
sourceData,
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
} = tableOptions;
const [{ pageIndex, pageSize }, setPagination] =
useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 1,
pageSize: 15,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
[pageIndex, pageSize],
);
/**
* Pagination control to move forward one page
* @returns void
* Pagination control to move forward one page
* @returns void
*/
const goToNextPage = () => {
setPagination({
@@ -39,10 +43,10 @@ export const T2Table = (tableOptions): ReactElement => {
pageSize,
});
nextPage(pageIndex, pageSize);
}
};
/**
* Pagination control to move backward one page
* Pagination control to move backward one page
* @returns void
**/
const goToPreviousPage = () => {
@@ -51,7 +55,7 @@ export const T2Table = (tableOptions): ReactElement => {
pageSize,
});
previousPage(pageIndex, pageSize);
}
};
const table = useReactTable({
data: sourceData,
@@ -75,15 +79,29 @@ export const T2Table = (tableOptions): ReactElement => {
{/* pagination controls */}
<nav className="pagination columns">
<div className="mr-4 has-text-weight-semibold has-text-left">
<p className="is-size-5">Page {pageIndex} of {Math.ceil(totalPages / pageSize)}</p>
{/* <p>{totalPages} comics in all</p> */}
<p className="is-size-5">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</p>
<p>{totalPages} comics in all</p>
</div>
<div className="field has-addons">
<div className="control">
<div className="button" onClick={() => goToPreviousPage()}> <i className="fas fa-chevron-left"></i></div>
<button
className="button"
onClick={() => goToPreviousPage()}
disabled={pageIndex === 1}
>
<i className="fas fa-chevron-left"></i>
</button>
</div>
<div className="control">
<div className="button" onClick={() => goToNextPage()}> <i className="fas fa-chevron-right"></i> </div>
<button
className="button"
onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
>
<i className="fas fa-chevron-right"></i>
</button>
</div>
<div className="field has-addons ml-5">
@@ -112,16 +130,13 @@ export const T2Table = (tableOptions): ReactElement => {
{table.getHeaderGroups().map((headerGroup, idx) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, idx) => (
<th
key={header.id}
colSpan={header.colSpan}
>
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
@@ -131,11 +146,8 @@ export const T2Table = (tableOptions): ReactElement => {
<tbody>
{table.getRowModel().rows.map((row, idx) => {
return (
<tr
key={row.id}
onClick={() => rowClickHandler(row)}
>
{row.getVisibleCells().map(cell => (
<tr key={row.id} onClick={() => rowClickHandler(row)}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>

View File

@@ -14,7 +14,6 @@ export const IMS_RAW_IMPORT_FAILED = "IMS_RAW_IMPORT_FAILED";
export const LS_IMPORT_CALL_IN_PROGRESS = "LS_IMPORT_CALL_IN_PROGRESS";
// Library import bull mq queue control
export const LS_TOGGLE_IMPORT_QUEUE = "LS_TOGGLE_IMPORT_QUEUE";
export const LS_QUEUE_DRAINED = "LS_QUEUE_DRAINED";
// ComicVine Metadata
export const IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS =
@@ -51,11 +50,12 @@ export const IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS =
"IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS";
export const IMS_COMIC_BOOK_GROUPS_CALL_FAILED =
"IMS_COMIC_BOOK_GROUPS_CALL_FAILED";
export const VOLUMES_FETCHED="VOLUMES_FETCHED";
export const VOLUMES_FETCHED = "VOLUMES_FETCHED";
// search results from the Search service
export const SS_SEARCH_RESULTS_FETCHED = "SS_SEARCH_RESULTS_FETCHED";
export const SS_SEARCH_RESULTS_FETCHED_SPECIAL = "SS_SEARCH_RESULTS_FETCHED_SPECIAL";
export const SS_SEARCH_RESULTS_FETCHED_SPECIAL =
"SS_SEARCH_RESULTS_FETCHED_SPECIAL";
export const SS_SEARCH_IN_PROGRESS = "SS_SEARCH_IN_PROGRESS";
export const SS_SEARCH_FAILED = "SS_SEARCH_FAILED";
@@ -105,7 +105,7 @@ export const AIRDCPP_SEARCH_RESULTS_UPDATED = "AIRDCPP_SEARCH_RESULTS_UPDATED";
export const AIRDCPP_SEARCH_COMPLETE = "AIRDCPP_SEARCH_COMPLETE";
// AirDC++ related library query for issues with bundles associated with them
export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES";
export const LIBRARY_ISSUE_BUNDLES = "LIBRARY_ISSUE_BUNDLES";
export const AIRDCPP_HUB_SEARCHES_SENT = "AIRDCPP_HUB_SEARCHES_SENT";
export const AIRDCPP_RESULT_DOWNLOAD_INITIATED =
@@ -124,10 +124,17 @@ export const AIRDCPP_TRANSFERS_FETCHED = "AIRDCPP_TRANSFERS_FETCHED";
// Comics marked as "wanted"
export const WANTED_COMICS_FETCHED = "WANTED_COMICS_FETCHED";
// LIBRARY SOCKET ENDPOINT
// LIBRARY Service import queue-related action types
export const LS_IMPORT = "LS_IMPORT";
export const LS_COVER_EXTRACTED = "LS_COVER_EXTRACTED";
export const LS_COVER_EXTRACTION_FAILED = "LS_COVER_EXTRACTION_FAILED";
export const LS_COMIC_ADDED = "LS_COMIC_ADDED";
export const LS_IMPORT_QUEUE_DRAINED = "LS_IMPORT_QUEUE_DRAINED";
export const LS_SET_QUEUE_STATUS = "LS_SET_QUEUE_STATUS";
export const RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION =
"RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION";
export const LS_IMPORT_JOB_STATISTICS_FETCHED =
"LS_IMPORT_JOB_STATISTICS_FETCHED";
// Settings
export const SETTINGS_CALL_IN_PROGRESS = "SETTINGS_CALL_IN_PROGRESS";
@@ -136,7 +143,12 @@ export const SETTINGS_CALL_FAILED = "SETTINGS_CALL_FAILED";
export const SETTINGS_OBJECT_DELETED = "SETTINGS_OBJECT_DELETED";
export const SETTINGS_DB_FLUSH_SUCCESS = "SETTINGS_DB_FLUSH_SUCCESS";
export const SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED = "SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED";
// Metron Metadata
export const METRON_DATA_FETCH_SUCCESS = "METRON_DATA_FETCH_SUCCESS";
export const METRON_DATA_FETCH_IN_PROGRESS = "METRON_DATA_FETCH_IN_PROGRESS";
export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR";
export const METRON_DATA_FETCH_ERROR = "METRON_DATA_FETCH_ERROR";
// service health statuses
export const LIBRARY_SERVICE_HEALTH = "LIBRARY_SERVICE_HEALTH";

View File

@@ -77,3 +77,17 @@ export const SOCKET_BASE_URI = hostURIBuilder({
port: "3001",
apiPath: `/`,
});
export const JOB_QUEUE_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3000",
apiPath: `/api/jobqueue`,
});
export const QBITTORRENT_SERVICE_BASE_URI = hostURIBuilder({
protocol: "http",
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
port: "3060",
apiPath: `/api/qbittorrent`,
});

View File

@@ -37,6 +37,34 @@
"displayName": "Additional Configuration"
}
]
},
{
"id": "qbittorrent",
"displayName": "qBittorrent",
"children": [
{
"id": "qbt-connection",
"displayName": "Connection"
},
{
"id": "qbt-additional-configuration",
"displayName": "qbt-configuration"
}
]
},
{
"id": "prowlarr",
"displayName": "Prowlarr",
"children": [
{
"id": "prowlarr-connection",
"displayName": "Connection"
},
{
"id": "prowlarr-additional-configuration",
"displayName": "prowlarr-configuration"
}
]
}
]
},
@@ -57,6 +85,21 @@
},
{
"id": 4,
"category": "statuses",
"displayName": "Service Status",
"children": [
{
"id": "core-service",
"displayName": "Core Services"
},
{
"id": "metadata-service",
"displayName": "Metadata Services"
}
]
},
{
"id": 5,
"category": "system",
"displayName": "System",
"children": [
@@ -67,7 +110,7 @@
]
},
{
"id": 5,
"id": 6,
"category": "acknowledgments",
"displayName": "Acknowledgments",
"children": [

View File

@@ -1,107 +0,0 @@
import { isEmpty, isUndefined } from "lodash";
import React, { createContext, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { toggleAirDCPPSocketConnectionStatus } from "../actions/airdcpp.actions";
import { getSettings } from "../actions/settings.actions";
import AirDCPPSocket from "../services/DcppSearchService";
const AirDCPPSocketContextProvider = ({ children }) => {
// setter for settings for use in the context consumer
const setSettings = (settingsObject) => {
persistSettings({
...airDCPPState,
airDCPPState: {
settings: settingsObject,
socket: {},
socketConectionInformation: {},
},
});
};
// 1. default zero-state for AirDC++ configuration
const initState = {
airDCPPState: {
settings: {},
socket: {},
socketConnectionInformation: {},
},
setSettings: setSettings,
};
const dispatch = useDispatch();
const [airDCPPState, persistSettings] = useState(initState);
const airDCPPSettings = useSelector(
(state: RootState) => state.settings.data,
);
// 1. get settings from mongo
useEffect(() => {
dispatch(getSettings());
}, []);
// 2. If available, init AirDC++ Socket with those settings
useEffect(() => {
if (!isEmpty(airDCPPSettings)) {
initializeAirDCPPSocket(airDCPPSettings);
}
}, [airDCPPSettings]);
// Method to init AirDC++ Socket with supplied settings
const initializeAirDCPPSocket = async (configuration) => {
console.log("[AirDCPP]: Initializing socket...");
const {
directConnect: {
client: { host },
},
} = configuration;
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${host.protocol}`,
hostname: `${host.hostname}:${host.port}`,
username: `${host.username}`,
password: `${host.password}`,
});
// connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
dispatch(toggleAirDCPPSocketConnectionStatus("connected", sessionInfo));
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
dispatch(
toggleAirDCPPSocketConnectionStatus("disconnected", {
reason,
code,
wasClean,
}),
);
};
const socketConnectionInformation = await initializedAirDCPPSocket.connect();
// update the state with the new socket connection information
persistSettings({
...airDCPPState,
airDCPPState: {
settings: configuration,
socket: initializedAirDCPPSocket,
socketConnectionInformation,
},
});
};
// the Provider gives access to the context to its children
return (
<AirDCPPSocketContext.Provider value={airDCPPState}>
{children}
</AirDCPPSocketContext.Provider>
);
};
const AirDCPPSocketContext = createContext({
airDCPPState: {},
saveSettings: () => { },
});
export { AirDCPPSocketContext, AirDCPPSocketContextProvider };

View File

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

View File

@@ -1,18 +1,45 @@
import React from "react";
import { render } from "react-dom";
import { Provider, connect } from "react-redux";
import { HistoryRouter as Router } from "redux-first-history/rr6";
import { store, history } from "./store/index";
import { createRoot } from "react-dom/client";
import App from "./components/App";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Settings from "./components/Settings/Settings";
import { ErrorPage } from "./components/shared/ErrorPage";
const rootEl = document.getElementById("root");
const root = createRoot(rootEl);
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
children: [
{ path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> },
{
path: "library",
element: <TabulatedContentContainer category="library" />,
},
{
path: "comic/details/:comicObjectId",
element: <ComicDetailContainer />,
},
{ path: "import", element: <Import path={"./comics"} /> },
],
},
]);
root.render(
<Provider store={store}>
<Router history={history}>
<App />
</Router>
</Provider>,
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>,
);

View File

@@ -15,10 +15,9 @@ import {
IMS_COMIC_BOOK_GROUPS_FETCHED,
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
LS_IMPORT,
LS_COVER_EXTRACTED,
LS_QUEUE_DRAINED,
LS_COVER_EXTRACTION_FAILED,
LS_COMIC_ADDED,
IMG_ANALYSIS_CALL_IN_PROGRESS,
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
@@ -30,6 +29,11 @@ import {
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
VOLUMES_FETCHED,
COMICBOOK_EXTRACTION_SUCCESS,
LIBRARY_SERVICE_HEALTH,
LS_IMPORT_QUEUE_DRAINED,
LS_SET_QUEUE_STATUS,
RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION,
LS_IMPORT_JOB_STATISTICS_FETCHED,
} from "../constants/action-types";
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
@@ -40,6 +44,7 @@ const initialState = {
SSCallInProgress: false,
imageAnalysisResults: {},
comicBookExtractionInProgress: false,
LSQueueImportStatus: undefined,
comicBookMetadata: [],
comicVolumeGroups: [],
isSocketConnected: false,
@@ -55,11 +60,14 @@ const initialState = {
libraryComics: [],
volumes: [],
librarySearchResultsFormatted: [],
librarySearchResultCount: 0,
lastQueueJob: "",
successfulJobCount: 0,
failedJobCount: 0,
importJobStatistics: [],
libraryQueueResults: [],
librarySearchError: {},
libraryServiceStatus: {},
};
function fileOpsReducer(state = initialState, action) {
switch (action.type) {
case IMS_COMICBOOK_METADATA_FETCHED:
@@ -149,17 +157,58 @@ function fileOpsReducer(state = initialState, action) {
case LS_IMPORT: {
return {
...state,
LSQueueImportStatus: "running",
};
}
case LS_COVER_EXTRACTED: {
console.log("BASH", action);
if(state.recentComics.length === 5) {
if (state.recentComics.length === 5) {
state.recentComics.pop();
}
return {
...state,
librarySearchResultCount: state.librarySearchResultCount + 1,
recentComics: [...state.recentComics, action.result.data.importResult]
successfulJobCount: action.completedJobCount,
lastQueueJob: action.importResult.rawFileDetails.name,
recentComics: [...state.recentComics, action.importResult],
};
}
case LS_COVER_EXTRACTION_FAILED: {
return {
...state,
failedJobCount: action.failedJobCount,
};
}
case LS_IMPORT_QUEUE_DRAINED: {
localStorage.removeItem("sessionId");
return {
...state,
LSQueueImportStatus: "drained",
};
}
case RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION: {
console.log("Restoring state for an active import in progress...");
return {
...state,
successfulJobCount: action.completedJobCount,
failedJobCount: action.failedJobCount,
LSQueueImportStatus: action.queueStatus,
};
}
case LS_SET_QUEUE_STATUS: {
return {
...state,
LSQueueImportStatus: action.data.queueStatus,
};
}
case LS_IMPORT_JOB_STATISTICS_FETCHED: {
return {
...state,
importJobStatistics: action.data,
};
}
@@ -191,14 +240,8 @@ function fileOpsReducer(state = initialState, action) {
};
}
}
case LS_QUEUE_DRAINED: {
console.log("drained", action);
return {
...state,
};
}
case LS_COMIC_ADDED: {
console.log("ADDED na anna", action);
return {
...state,
};
@@ -271,7 +314,12 @@ function fileOpsReducer(state = initialState, action) {
SSCallInProgress: false,
};
}
case LIBRARY_SERVICE_HEALTH: {
return {
...state,
libraryServiceStatus: action.status,
};
}
case FILEOPS_STATE_RESET: {
return {
...state,
@@ -283,4 +331,4 @@ function fileOpsReducer(state = initialState, action) {
}
}
export default fileOpsReducer;
export default fileOpsReducer;

View File

@@ -1,11 +1,11 @@
import comicinfoReducer from "../reducers/comicinfo.reducer";
import fileOpsReducer from "../reducers/fileops.reducer";
import airdcppReducer from "../reducers/airdcpp.reducer";
import settingsReducer from "../reducers/settings.reducer";
// import settingsReducer from "../reducers/settings.reducer";
export const reducers = {
comicInfo: comicinfoReducer,
fileOps: fileOpsReducer,
airdcpp: airdcppReducer,
settings: settingsReducer,
// settings: settingsReducer,
};

View File

@@ -1,49 +1,67 @@
import {
SETTINGS_CALL_FAILED,
SETTINGS_OBJECT_FETCHED,
SETTINGS_OBJECT_DELETED,
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
} from "../constants/action-types";
const initialState = {
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { RootState } from "../store";
import { isUndefined } from "lodash";
import { SETTINGS_SERVICE_BASE_URI } from "../constants/endpoints";
export interface InitialState {
data: object;
inProgress: boolean;
dbFlushed: boolean;
torrentsList: Array<any>;
}
const initialState: InitialState = {
data: {},
inProgress: false,
DbFlushed: false,
dbFlushed: false,
torrentsList: [],
};
function settingsReducer(state = initialState, action) {
switch (action.type) {
case SETTINGS_CALL_IN_PROGRESS:
return {
...state,
inProgress: true,
};
export const settingsSlice = createSlice({
name: "settings",
initialState,
reducers: {
SETTINGS_CALL_IN_PROGRESS: (state) => {
state.inProgress = true;
},
case SETTINGS_OBJECT_FETCHED:
return {
...state,
data: action.data,
inProgress: false,
};
SETTINGS_OBJECT_FETCHED: (state, action) => {
state.data = action.payload;
state.inProgress = false;
},
case SETTINGS_OBJECT_DELETED:
return {
...state,
data: action.data,
inProgress: false,
};
SETTINGS_OBJECT_DELETED: (state, action) => {
state.data = action.payload;
state.inProgress = false;
},
case SETTINGS_DB_FLUSH_SUCCESS:
SETTINGS_DB_FLUSH_SUCCESS: (state, action) => {
state.dbFlushed = action.payload;
state.inProgress = false;
},
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED: (state, action) => {
console.log(state);
return {
...state,
DbFlushed: action.data,
inProgress: false,
};
console.log(action);
state.torrentsList = action.payload;
},
},
});
default:
return { ...state };
export const {
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_OBJECT_FETCHED,
SETTINGS_OBJECT_DELETED,
SETTINGS_DB_FLUSH_SUCCESS,
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} = settingsSlice.actions;
// Other code such as selectors can use the imported `RootState` type
export const torrentsList = (state: RootState) => state.settings.torrentsList;
export const qBittorrentSettings = (state: RootState) => {
console.log(state);
if (!isUndefined(state.settings?.data?.bittorrent)) {
return state.settings?.data?.bittorrent.client.host;
}
}
export default settingsReducer;
};
export default settingsSlice.reducer;

View File

@@ -0,0 +1,11 @@
const socketIOMiddleware = (socket) => {
return (store) => (next) => (action) => {
if (action.type === "EMIT_SOCKET_EVENT") {
const { event, data } = action.payload;
socket.emit(event, data);
}
return next(action);
};
};
export default socketIOMiddleware;

View File

@@ -0,0 +1,10 @@
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../../constants/endpoints";
const sessionId = localStorage.getItem("sessionId");
const socketIOConnectionInstance = io(SOCKET_BASE_URI, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
export default socketIOConnectionInstance;

View File

@@ -0,0 +1,14 @@
import { isNil, isUndefined } from "lodash";
export const hostNameValidator = (hostname: string): string | undefined => {
// https://stackoverflow.com/a/3824105/656708
const hostnameRegex =
/^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$/;
if (!isUndefined(hostname)) {
const matches = hostname.match(hostnameRegex);
return !isNil(matches) && matches.length > 0
? undefined
: "Enter a valid hostname";
}
};

View File

@@ -1,32 +1,219 @@
import { createStore, combineReducers, applyMiddleware } from "redux";
import { createHashHistory } from "history";
import { composeWithDevTools } from "@redux-devtools/extension";
import thunk from "redux-thunk";
import { createReduxHistoryContext } from "redux-first-history";
import { reducers } from "../reducers/index";
import { io } from "socket.io-client";
import socketIoMiddleware from "redux-socket.io-middleware";
import { create } from "zustand";
import { isEmpty, isNil, isUndefined } from "lodash";
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints";
const socketConnection = io(SOCKET_BASE_URI, { transports: ["websocket"] });
import { produce } from "immer";
import AirDCPPSocket from "../services/DcppSearchService";
import axios from "axios";
const { createReduxHistory, routerMiddleware, routerReducer } =
createReduxHistoryContext({
history: createHashHistory(),
/* Broadly, this file sets up:
* 1. The zustand-based global client state
* 2. socket.io client
* 3. AirDC++ websocket connection
*/
export const useStore = create((set, get) => ({
// AirDC++ state
airDCPPSocketInstance: {},
airDCPPSocketConnected: false,
airDCPPDisconnectionInfo: {},
airDCPPClientConfiguration: {},
airDCPPSessionInformation: {},
setAirDCPPSocketConnectionStatus: () =>
set((value) => ({
airDCPPSocketConnected: value,
})),
airDCPPDownloadTick: {},
// Socket.io state
socketIOInstance: {},
// Import job queue and associated statuses
importJobQueue: {
successfulJobCount: 0,
failedJobCount: 0,
status: undefined,
setStatus: (status: string) =>
set(
produce((draftState) => {
draftState.importJobQueue.status = status;
}),
),
setJobCount: (jobType: string, count: Number) => {
switch (jobType) {
case "successful":
set(
produce((draftState) => {
draftState.importJobQueue.successfulJobCount = count;
}),
);
break;
case "failed":
set(
produce((draftState) => {
draftState.importJobQueue.failedJobCount = count;
}),
);
break;
}
},
mostRecentImport: null,
setMostRecentImport: (fileName: string) => {
set(
produce((state) => {
state.importJobQueue.mostRecentImport = fileName;
}),
);
},
},
}));
const { getState, setState } = useStore;
/** Socket.IO initialization **/
// 1. Fetch sessionId from localStorage
const sessionId = localStorage.getItem("sessionId");
// 2. socket.io instantiation
const socketIOInstance = io(SOCKET_BASE_URI, {
transports: ["websocket"],
withCredentials: true,
query: { sessionId },
});
// 3. Set the instance in global state
setState({
socketIOInstance,
});
// Socket.io-based session restoration
if (!isNil(sessionId)) {
// 1. Resume the session
socketIOInstance.emit(
"call",
"socket.resumeSession",
{
sessionId,
},
(data) => console.log(data),
);
} else {
// 1. Inititalize the session and persist the sessionId to localStorage
socketIOInstance.on("sessionInitialized", (sessionId) => {
localStorage.setItem("sessionId", sessionId);
});
}
// 2. If a job is in progress, restore the job counts and persist those to global state
socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
console.log("Active import in progress detected; restoring counts...");
const { completedJobCount, failedJobCount, queueStatus } = data;
setState((state) => ({
importJobQueue: {
...state.importJobQueue,
successfulJobCount: completedJobCount,
failedJobCount,
status: queueStatus,
},
}));
});
export const store = createStore(
combineReducers({
router: routerReducer,
...reducers,
}),
composeWithDevTools(
applyMiddleware(
socketIoMiddleware(socketConnection),
thunk,
routerMiddleware,
),
// window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
),
);
export const history = createReduxHistory(store);
/**
* Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection
* @returns Initialized AirDC++ connection socket instance
*/
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
try {
console.log("[AirDCPP]: Initializing socket...");
const initializedAirDCPPSocket = new AirDCPPSocket({
protocol: `${configuration.protocol}`,
hostname: `${configuration.hostname}:${configuration.port}`,
username: `${configuration.username}`,
password: `${configuration.password}`,
});
// Set up connect and disconnect handlers
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
// update global state with socket connection status
setState({
airDCPPSocketConnected: true,
});
};
initializedAirDCPPSocket.onDisconnected = async (
reason,
code,
wasClean,
) => {
// update global state with socket connection status
setState({
disconnectionInfo: { reason, code, wasClean },
airDCPPSocketConnected: false,
});
};
// AirDC++ Socket-related connection and post-connection
// Attempt connection
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
setState({
airDCPPSessionInformation,
});
// Set up event listeners
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_tick",
async (downloadProgressData) => {
console.log(downloadProgressData);
setState({
airDCPPDownloadTick: downloadProgressData,
});
},
);
initializedAirDCPPSocket.addListener(
"queue",
"queue_bundle_added",
async (data) => {
console.log("JEMEN:", data);
},
);
initializedAirDCPPSocket.addListener(
`queue`,
"queue_bundle_status",
async (bundleData) => {
let count = 0;
if (bundleData.status.completed && bundleData.status.downloaded) {
// dispatch the action for raw import, with the metadata
if (count < 1) {
console.log(`[AirDCPP]: Download complete.`);
count += 1;
}
}
},
);
return initializedAirDCPPSocket;
} catch (error) {
console.error(error);
}
};
// 1. get settings from mongo
const { data } = await axios({
url: "http://localhost:3000/api/settings/getAllSettings",
method: "GET",
});
const directConnectConfiguration = data?.directConnect?.client.host;
// 2. If available, init AirDC++ Socket with those settings
if (!isNil(directConnectConfiguration)) {
const airDCPPSocketInstance = await initializeAirDCPPSocket(
directConnectConfiguration,
);
setState({
airDCPPSocketInstance,
airDCPPClientConfiguration: directConnectConfiguration,
});
} else {
console.log("problem");
}
console.log("connected?", getState());

View File

@@ -4,7 +4,7 @@ import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Card } from '../components/Carda';
import { Card } from '../components/shared/Carda';
import "../assets/scss/App.scss";
export default {
/* 👇 The title prop is optional.

View File

@@ -6,6 +6,11 @@ export default defineConfig({
publicDir: "public",
base: "",
build: "esnext",
esbuild: {
supported: {
"top-level-await": true, //browsers can handle top-level-await features
},
},
server: { host: true },
plugins: [
nodeResolve({

12798
yarn.lock

File diff suppressed because it is too large Load Diff