Massive ts error cleanup
This commit is contained in:
20050
package-lock.json
generated
Normal file
20050
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,7 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.0",
|
||||||
"@graphql-codegen/cli": "^6.1.2",
|
"@graphql-codegen/cli": "^6.1.2",
|
||||||
"@graphql-codegen/typescript": "^5.0.8",
|
"@graphql-codegen/typescript": "^5.0.8",
|
||||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||||
@@ -110,15 +111,17 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/ellipsize": "^0.1.3",
|
"@types/ellipsize": "^0.1.3",
|
||||||
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/prop-types": "^15.7.15",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
|
"@types/react-table": "^7.7.20",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"docdash": "^2.0.2",
|
"docdash": "^2.0.2",
|
||||||
"@eslint/js": "^10.0.0",
|
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-css-modules": "^2.12.0",
|
"eslint-plugin-css-modules": "^2.12.0",
|
||||||
|
|||||||
@@ -32,9 +32,38 @@ import {
|
|||||||
AIRDCPP_SOCKET_DISCONNECTED,
|
AIRDCPP_SOCKET_DISCONNECTED,
|
||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
import axios from "axios";
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
|
||||||
import type { AirDCPPSearchData } from "../types";
|
import type { AirDCPPSearchData } from "../types";
|
||||||
|
import type { Dispatch } from "react";
|
||||||
|
|
||||||
|
/** Redux action type for AirDC++ actions */
|
||||||
|
interface AirDCPPAction {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
downloadResult?: unknown;
|
||||||
|
bundleDBImportResult?: AxiosResponse | null;
|
||||||
|
bundles?: AirDCPPBundle[];
|
||||||
|
issue_bundles?: AxiosResponse;
|
||||||
|
comicBookDetail?: unknown;
|
||||||
|
IMS_inProgress?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AirDC++ Bundle type */
|
||||||
|
interface AirDCPPBundle {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download item in acquisition data */
|
||||||
|
interface AirDCPPDownloadItem {
|
||||||
|
bundleId: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thunk dispatch type */
|
||||||
|
type ThunkDispatch = Dispatch<AirDCPPAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a promise that resolves after a specified delay.
|
* Creates a promise that resolves after a specified delay.
|
||||||
@@ -58,7 +87,7 @@ export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
|
|||||||
* @returns {Function} Redux thunk function
|
* @returns {Function} Redux thunk function
|
||||||
*/
|
*/
|
||||||
export const toggleAirDCPPSocketConnectionStatus =
|
export const toggleAirDCPPSocketConnectionStatus =
|
||||||
(status: String, payload?: any) => async (dispatch) => {
|
(status: String, payload?: unknown) => async (dispatch: ThunkDispatch) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "connected":
|
case "connected":
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -102,16 +131,16 @@ export const downloadAirDCPPItem =
|
|||||||
comicObjectId: String,
|
comicObjectId: String,
|
||||||
name: String,
|
name: String,
|
||||||
size: Number,
|
size: Number,
|
||||||
type: any,
|
type: unknown,
|
||||||
ADCPPSocket: any,
|
ADCPPSocket: { isConnected: () => boolean; connect: () => Promise<void>; post: (url: string) => Promise<{ bundle_info: { id: string } }> },
|
||||||
credentials: any,
|
credentials: unknown,
|
||||||
): void =>
|
) =>
|
||||||
async (dispatch) => {
|
async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
if (!ADCPPSocket.isConnected()) {
|
if (!ADCPPSocket.isConnected()) {
|
||||||
await ADCPPSocket.connect();
|
await ADCPPSocket.connect();
|
||||||
}
|
}
|
||||||
let bundleDBImportResult = {};
|
let bundleDBImportResult: AxiosResponse | null = null;
|
||||||
const downloadResult = await ADCPPSocket.post(
|
const downloadResult = await ADCPPSocket.post(
|
||||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||||
);
|
);
|
||||||
@@ -140,7 +169,7 @@ export const downloadAirDCPPItem =
|
|||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||||
comicBookDetail: bundleDBImportResult.data,
|
comicBookDetail: bundleDBImportResult?.data,
|
||||||
IMS_inProgress: false,
|
IMS_inProgress: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -161,8 +190,8 @@ export const downloadAirDCPPItem =
|
|||||||
* @throws {Error} If fetching comic or bundles fails
|
* @throws {Error} If fetching comic or bundles fails
|
||||||
*/
|
*/
|
||||||
export const getBundlesForComic =
|
export const getBundlesForComic =
|
||||||
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
|
(comicObjectId: string, ADCPPSocket: { isConnected: () => boolean; connect: () => Promise<void>; get: (url: string) => Promise<AirDCPPBundle> }, credentials: unknown) =>
|
||||||
async (dispatch) => {
|
async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
if (!ADCPPSocket.isConnected()) {
|
if (!ADCPPSocket.isConnected()) {
|
||||||
await ADCPPSocket.connect();
|
await ADCPPSocket.connect();
|
||||||
@@ -181,7 +210,7 @@ export const getBundlesForComic =
|
|||||||
if (comicObject.data.acquisition.directconnect) {
|
if (comicObject.data.acquisition.directconnect) {
|
||||||
const filteredBundles =
|
const filteredBundles =
|
||||||
comicObject.data.acquisition.directconnect.downloads.map(
|
comicObject.data.acquisition.directconnect.downloads.map(
|
||||||
async ({ bundleId }) => {
|
async ({ bundleId }: AirDCPPDownloadItem) => {
|
||||||
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
|
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -207,7 +236,7 @@ export const getBundlesForComic =
|
|||||||
* @throws {Error} If fetching transfers fails
|
* @throws {Error} If fetching transfers fails
|
||||||
*/
|
*/
|
||||||
export const getTransfers =
|
export const getTransfers =
|
||||||
(ADCPPSocket: any, credentials: any) => async (dispatch) => {
|
(ADCPPSocket: { isConnected: () => boolean; connect: () => Promise<void>; get: (url: string, options: Record<string, unknown>) => Promise<AirDCPPBundle[]> }, credentials: unknown) => async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
if (!ADCPPSocket.isConnected()) {
|
if (!ADCPPSocket.isConnected()) {
|
||||||
await ADCPPSocket.connect();
|
await ADCPPSocket.connect();
|
||||||
@@ -218,7 +247,7 @@ export const getTransfers =
|
|||||||
type: AIRDCPP_TRANSFERS_FETCHED,
|
type: AIRDCPP_TRANSFERS_FETCHED,
|
||||||
bundles,
|
bundles,
|
||||||
});
|
});
|
||||||
const bundleIds = bundles.map((bundle) => bundle.id);
|
const bundleIds = bundles.map((bundle: AirDCPPBundle) => bundle.id);
|
||||||
// get issues with matching bundleIds
|
// get issues with matching bundleIds
|
||||||
const issue_bundles = await axios({
|
const issue_bundles = await axios({
|
||||||
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,
|
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,
|
||||||
|
|||||||
@@ -28,6 +28,52 @@ import {
|
|||||||
COMICVINE_SERVICE_URI,
|
COMICVINE_SERVICE_URI,
|
||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
} from "../constants/endpoints";
|
} from "../constants/endpoints";
|
||||||
|
import type { Dispatch } from "react";
|
||||||
|
|
||||||
|
/** Redux action type for comic info actions */
|
||||||
|
interface ComicInfoAction {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
inProgress?: boolean;
|
||||||
|
searchResults?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
issues?: unknown[];
|
||||||
|
matches?: unknown;
|
||||||
|
comicBookDetail?: unknown;
|
||||||
|
comicBooks?: unknown[];
|
||||||
|
IMS_inProgress?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for the weekly pull list */
|
||||||
|
interface PullListOptions {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for the comicinfo API call */
|
||||||
|
interface ComicInfoAPIOptions {
|
||||||
|
callURIAction: string;
|
||||||
|
callMethod: string;
|
||||||
|
callParams: Record<string, unknown>;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue type from ComicVine */
|
||||||
|
interface ComicVineIssue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
issue_number: string;
|
||||||
|
volume: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match object for ComicVine metadata */
|
||||||
|
interface ComicVineMatch {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thunk dispatch type */
|
||||||
|
type ThunkDispatch = Dispatch<ComicInfoAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate-limited axios instance for ComicVine API calls.
|
* Rate-limited axios instance for ComicVine API calls.
|
||||||
@@ -53,7 +99,7 @@ const cachedAxios = setupCache(axios);
|
|||||||
* @param {Object} options - Query parameters for the pull list request
|
* @param {Object} options - Query parameters for the pull list request
|
||||||
* @returns {Function} Redux thunk function that dispatches CV_WEEKLY_PULLLIST_FETCHED
|
* @returns {Function} Redux thunk function that dispatches CV_WEEKLY_PULLLIST_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getWeeklyPullList = (options) => async (dispatch) => {
|
export const getWeeklyPullList = (options: PullListOptions) => async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||||
@@ -84,7 +130,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
|
|||||||
* @param {any} [options.data] - Request body data
|
* @param {any} [options.data] - Request body data
|
||||||
* @returns {Function} Redux thunk function that dispatches appropriate action based on callURIAction
|
* @returns {Function} Redux thunk function that dispatches appropriate action based on callURIAction
|
||||||
*/
|
*/
|
||||||
export const comicinfoAPICall = (options) => async (dispatch) => {
|
export const comicinfoAPICall = (options: ComicInfoAPIOptions) => async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -128,7 +174,7 @@ export const comicinfoAPICall = (options) => async (dispatch) => {
|
|||||||
* @returns {Function} Redux thunk function that dispatches CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS
|
* @returns {Function} Redux thunk function that dispatches CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS
|
||||||
*/
|
*/
|
||||||
export const getIssuesForSeries =
|
export const getIssuesForSeries =
|
||||||
(comicObjectID: string) => async (dispatch) => {
|
(comicObjectID: string) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -161,11 +207,11 @@ export const getIssuesForSeries =
|
|||||||
* @param {string} issues[].volume.name - Volume name
|
* @param {string} issues[].volume.name - Volume name
|
||||||
* @returns {Function} Redux thunk function that dispatches CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED
|
* @returns {Function} Redux thunk function that dispatches CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED
|
||||||
*/
|
*/
|
||||||
export const analyzeLibrary = (issues) => async (dispatch) => {
|
export const analyzeLibrary = (issues: ComicVineIssue[]) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
const queryObjects = issues.map((issue) => {
|
const queryObjects = issues.map((issue: ComicVineIssue) => {
|
||||||
const { id, name, issue_number } = issue;
|
const { id, name, issue_number } = issue;
|
||||||
return {
|
return {
|
||||||
issueId: id,
|
issueId: id,
|
||||||
@@ -194,7 +240,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
|
|||||||
*
|
*
|
||||||
* @returns {Function} Redux thunk function that dispatches LIBRARY_STATISTICS_FETCHED
|
* @returns {Function} Redux thunk function that dispatches LIBRARY_STATISTICS_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getLibraryStatistics = () => async (dispatch) => {
|
export const getLibraryStatistics = () => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
type: LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -217,7 +263,7 @@ export const getLibraryStatistics = () => async (dispatch) => {
|
|||||||
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOK_DB_OBJECT_FETCHED
|
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOK_DB_OBJECT_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getComicBookDetailById =
|
export const getComicBookDetailById =
|
||||||
(comicBookObjectId: string) => async (dispatch) => {
|
(comicBookObjectId: string) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
IMS_inProgress: true,
|
IMS_inProgress: true,
|
||||||
@@ -244,7 +290,7 @@ export const getComicBookDetailById =
|
|||||||
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED
|
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getComicBooksDetailsByIds =
|
export const getComicBooksDetailsByIds =
|
||||||
(comicBookObjectIds: Array<string>) => async (dispatch) => {
|
(comicBookObjectIds: Array<string>) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
IMS_inProgress: true,
|
IMS_inProgress: true,
|
||||||
@@ -272,7 +318,7 @@ export const getComicBooksDetailsByIds =
|
|||||||
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOK_DB_OBJECT_FETCHED
|
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOK_DB_OBJECT_FETCHED
|
||||||
*/
|
*/
|
||||||
export const applyComicVineMatch =
|
export const applyComicVineMatch =
|
||||||
(match, comicObjectId) => async (dispatch) => {
|
(match: ComicVineMatch, comicObjectId: string) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||||
IMS_inProgress: true,
|
IMS_inProgress: true,
|
||||||
|
|||||||
@@ -43,6 +43,55 @@ import {
|
|||||||
} from "../constants/action-types";
|
} from "../constants/action-types";
|
||||||
|
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
import type { Dispatch } from "react";
|
||||||
|
|
||||||
|
/** Redux action type for fileops actions */
|
||||||
|
interface FileOpsAction {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
searchResults?: unknown;
|
||||||
|
searchQueryObject?: unknown;
|
||||||
|
importResult?: unknown;
|
||||||
|
importError?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
meta?: { remote: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for fetching comic books */
|
||||||
|
interface GetComicBooksOptions {
|
||||||
|
paginationOptions: Record<string, unknown>;
|
||||||
|
predicate: Record<string, unknown>;
|
||||||
|
comicStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search payload for ComicVine matching */
|
||||||
|
interface CVSearchPayload {
|
||||||
|
rawFileDetails: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue search query structure */
|
||||||
|
interface IssueSearchQuery {
|
||||||
|
inferredIssueDetails: {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search query options */
|
||||||
|
interface SearchQueryOptions {
|
||||||
|
trigger: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match object for ComicVine */
|
||||||
|
interface CVMatch {
|
||||||
|
score?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thunk dispatch type */
|
||||||
|
type ThunkDispatch = Dispatch<FileOpsAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redux thunk action creator to fetch library service health status.
|
* Redux thunk action creator to fetch library service health status.
|
||||||
@@ -51,7 +100,7 @@ import { isNil } from "lodash";
|
|||||||
* @param {string} [serviceName] - Optional specific service name to check
|
* @param {string} [serviceName] - Optional specific service name to check
|
||||||
* @returns {Function} Redux thunk function that dispatches LIBRARY_SERVICE_HEALTH
|
* @returns {Function} Redux thunk function that dispatches LIBRARY_SERVICE_HEALTH
|
||||||
*/
|
*/
|
||||||
export const getServiceStatus = (serviceName?: string) => async (dispatch) => {
|
export const getServiceStatus = (serviceName?: string) => async (dispatch: ThunkDispatch) => {
|
||||||
axios
|
axios
|
||||||
.request({
|
.request({
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
|
url: `${LIBRARY_SERVICE_BASE_URI}/getHealthInformation`,
|
||||||
@@ -86,7 +135,7 @@ export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
|||||||
* Fetches comic book covers along with some metadata
|
* Fetches comic book covers along with some metadata
|
||||||
* @return the comic book metadata
|
* @return the comic book metadata
|
||||||
*/
|
*/
|
||||||
export const fetchComicBookMetadata = () => async (dispatch) => {
|
export const fetchComicBookMetadata = () => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LS_IMPORT_CALL_IN_PROGRESS,
|
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -113,7 +162,7 @@ export const fetchComicBookMetadata = () => async (dispatch) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImportJobResultStatistics = () => async (dispatch) => {
|
export const getImportJobResultStatistics = () => async (dispatch: ThunkDispatch) => {
|
||||||
const result = await axios.request({
|
const result = await axios.request({
|
||||||
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
|
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -125,7 +174,7 @@ export const getImportJobResultStatistics = () => async (dispatch) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const setQueueControl =
|
export const setQueueControl =
|
||||||
(queueAction: string, queueStatus: string) => async (dispatch) => {
|
(queueAction: string, queueStatus: string) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LS_SET_QUEUE_STATUS,
|
type: LS_SET_QUEUE_STATUS,
|
||||||
meta: { remote: true },
|
meta: { remote: true },
|
||||||
@@ -138,7 +187,7 @@ export const setQueueControl =
|
|||||||
* @return metadata for the comic book object categories
|
* @return metadata for the comic book object categories
|
||||||
* @param options
|
* @param options
|
||||||
**/
|
**/
|
||||||
export const getComicBooks = (options) => async (dispatch) => {
|
export const getComicBooks = (options: GetComicBooksOptions) => async (dispatch: ThunkDispatch) => {
|
||||||
const { paginationOptions, predicate, comicStatus } = options;
|
const { paginationOptions, predicate, comicStatus } = options;
|
||||||
|
|
||||||
const response = await axios.request({
|
const response = await axios.request({
|
||||||
@@ -174,7 +223,7 @@ export const getComicBooks = (options) => async (dispatch) => {
|
|||||||
* @param payload
|
* @param payload
|
||||||
*/
|
*/
|
||||||
export const importToDB =
|
export const importToDB =
|
||||||
(sourceName: string, metadata?: any) => (dispatch) => {
|
(sourceName: string, metadata?: unknown) => (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
const comicBookMetadata = {
|
const comicBookMetadata = {
|
||||||
importType: "new",
|
importType: "new",
|
||||||
@@ -218,7 +267,7 @@ export const importToDB =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchVolumeGroups = () => async (dispatch) => {
|
export const fetchVolumeGroups = () => async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||||
@@ -236,7 +285,7 @@ export const fetchVolumeGroups = () => async (dispatch) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const fetchComicVineMatches =
|
export const fetchComicVineMatches =
|
||||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
(searchPayload: CVSearchPayload, issueSearchQuery: IssueSearchQuery, seriesSearchQuery?: unknown) => async (dispatch: ThunkDispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_API_CALL_IN_PROGRESS,
|
type: CV_API_CALL_IN_PROGRESS,
|
||||||
@@ -266,14 +315,14 @@ export const fetchComicVineMatches =
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
let matches: any = [];
|
let matches: CVMatch[] = [];
|
||||||
if (
|
if (
|
||||||
!isNil(response.data.results) &&
|
!isNil(response.data.results) &&
|
||||||
response.data.results.length === 1
|
response.data.results.length === 1
|
||||||
) {
|
) {
|
||||||
matches = response.data.results;
|
matches = response.data.results;
|
||||||
} else {
|
} else {
|
||||||
matches = response.data.map((match) => match);
|
matches = response.data.map((match: CVMatch) => match);
|
||||||
}
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CV_SEARCH_SUCCESS,
|
type: CV_SEARCH_SUCCESS,
|
||||||
@@ -300,8 +349,8 @@ export const fetchComicVineMatches =
|
|||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export const extractComicArchive =
|
export const extractComicArchive =
|
||||||
(path: string, options: any): any =>
|
(path: string, options: Record<string, unknown>) =>
|
||||||
async (dispatch) => {
|
async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -324,7 +373,7 @@ export const extractComicArchive =
|
|||||||
* @param {any} options
|
* @param {any} options
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export const searchIssue = (query, options) => async (dispatch) => {
|
export const searchIssue = (query: Record<string, unknown>, options: SearchQueryOptions) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SS_SEARCH_IN_PROGRESS,
|
type: SS_SEARCH_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -374,7 +423,7 @@ export const searchIssue = (query, options) => async (dispatch) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const analyzeImage =
|
export const analyzeImage =
|
||||||
(imageFilePath: string | Buffer) => async (dispatch) => {
|
(imageFilePath: string | Buffer) => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: FILEOPS_STATE_RESET,
|
type: FILEOPS_STATE_RESET,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,32 @@ import axios from "axios";
|
|||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
import { METRON_SERVICE_URI } from "../constants/endpoints";
|
import { METRON_SERVICE_URI } from "../constants/endpoints";
|
||||||
|
|
||||||
|
/** Options for fetching Metron resources */
|
||||||
|
interface MetronFetchOptions {
|
||||||
|
query: {
|
||||||
|
page: number;
|
||||||
|
resource?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metron resource result item */
|
||||||
|
interface MetronResultItem {
|
||||||
|
name?: string;
|
||||||
|
__str__?: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metron resource result format */
|
||||||
|
interface MetronResourceResult {
|
||||||
|
options: Array<{ label: string; value: number }>;
|
||||||
|
hasMore: boolean;
|
||||||
|
additional: {
|
||||||
|
page: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} MetronResourceResult
|
* @typedef {Object} MetronResourceResult
|
||||||
* @property {Array<{label: string, value: number}>} options - Formatted options for select components
|
* @property {Array<{label: string, value: number}>} options - Formatted options for select components
|
||||||
@@ -31,12 +57,12 @@ import { METRON_SERVICE_URI } from "../constants/endpoints";
|
|||||||
* });
|
* });
|
||||||
* // Returns: { options: [{label: "DC Comics", value: 1}], hasMore: true, additional: { page: 2 } }
|
* // Returns: { options: [{label: "DC Comics", value: 1}], hasMore: true, additional: { page: 2 } }
|
||||||
*/
|
*/
|
||||||
export const fetchMetronResource = async (options) => {
|
export const fetchMetronResource = async (options: MetronFetchOptions): Promise<MetronResourceResult> => {
|
||||||
const metronResourceResults = await axios.post(
|
const metronResourceResults = await axios.post(
|
||||||
`${METRON_SERVICE_URI}/fetchResource`,
|
`${METRON_SERVICE_URI}/fetchResource`,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const results = metronResourceResults.data.results.map((result) => {
|
const results = metronResourceResults.data.results.map((result: MetronResultItem) => {
|
||||||
return {
|
return {
|
||||||
label: result.name || result.__str__,
|
label: result.name || result.__str__,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
|
|||||||
@@ -11,12 +11,37 @@ import {
|
|||||||
SETTINGS_CALL_IN_PROGRESS,
|
SETTINGS_CALL_IN_PROGRESS,
|
||||||
SETTINGS_DB_FLUSH_SUCCESS,
|
SETTINGS_DB_FLUSH_SUCCESS,
|
||||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||||
} from "../reducers/settings.reducer";
|
} from "../constants/action-types";
|
||||||
import {
|
import {
|
||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
SETTINGS_SERVICE_BASE_URI,
|
SETTINGS_SERVICE_BASE_URI,
|
||||||
QBITTORRENT_SERVICE_BASE_URI,
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
} from "../constants/endpoints";
|
} from "../constants/endpoints";
|
||||||
|
import type { Dispatch } from "react";
|
||||||
|
|
||||||
|
/** Redux action type for settings actions */
|
||||||
|
interface SettingsAction {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host info for qBittorrent */
|
||||||
|
interface QBittorrentHostInfo {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host info for Prowlarr */
|
||||||
|
interface ProwlarrHostInfo {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thunk dispatch type */
|
||||||
|
type ThunkDispatch = Dispatch<SettingsAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redux thunk action creator to fetch application settings.
|
* Redux thunk action creator to fetch application settings.
|
||||||
@@ -25,7 +50,7 @@ import {
|
|||||||
* @param {string} [settingsKey] - Optional specific settings key to fetch
|
* @param {string} [settingsKey] - Optional specific settings key to fetch
|
||||||
* @returns {Function} Redux thunk function that dispatches SETTINGS_OBJECT_FETCHED
|
* @returns {Function} Redux thunk function that dispatches SETTINGS_OBJECT_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getSettings = (settingsKey?) => async (dispatch) => {
|
export const getSettings = (settingsKey?: string) => async (dispatch: ThunkDispatch) => {
|
||||||
const result = await axios({
|
const result = await axios({
|
||||||
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -45,7 +70,7 @@ export const getSettings = (settingsKey?) => async (dispatch) => {
|
|||||||
*
|
*
|
||||||
* @returns {Function} Redux thunk function that dispatches SETTINGS_OBJECT_FETCHED with empty data
|
* @returns {Function} Redux thunk function that dispatches SETTINGS_OBJECT_FETCHED with empty data
|
||||||
*/
|
*/
|
||||||
export const deleteSettings = () => async (dispatch) => {
|
export const deleteSettings = () => async (dispatch: ThunkDispatch) => {
|
||||||
const result = await axios({
|
const result = await axios({
|
||||||
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
|
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -65,7 +90,7 @@ export const deleteSettings = () => async (dispatch) => {
|
|||||||
*
|
*
|
||||||
* @returns {Function} Redux thunk function that dispatches SETTINGS_DB_FLUSH_SUCCESS
|
* @returns {Function} Redux thunk function that dispatches SETTINGS_DB_FLUSH_SUCCESS
|
||||||
*/
|
*/
|
||||||
export const flushDb = () => async (dispatch) => {
|
export const flushDb = () => async (dispatch: ThunkDispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SETTINGS_CALL_IN_PROGRESS,
|
type: SETTINGS_CALL_IN_PROGRESS,
|
||||||
});
|
});
|
||||||
@@ -94,7 +119,7 @@ export const flushDb = () => async (dispatch) => {
|
|||||||
* @param {string} [hostInfo.password] - Authentication password
|
* @param {string} [hostInfo.password] - Authentication password
|
||||||
* @returns {Function} Redux thunk function that dispatches SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED
|
* @returns {Function} Redux thunk function that dispatches SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED
|
||||||
*/
|
*/
|
||||||
export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
|
export const getQBitTorrentClientInfo = (hostInfo: QBittorrentHostInfo) => async (dispatch: ThunkDispatch) => {
|
||||||
await axios.request({
|
await axios.request({
|
||||||
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
|
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -122,4 +147,4 @@ export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
|
|||||||
* @returns {Function} Redux thunk function (currently not implemented)
|
* @returns {Function} Redux thunk function (currently not implemented)
|
||||||
* @todo Implement Prowlarr connection verification
|
* @todo Implement Prowlarr connection verification
|
||||||
*/
|
*/
|
||||||
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};
|
export const getProwlarrConnectionInfo = (hostInfo: ProwlarrHostInfo) => async (_dispatch: ThunkDispatch) => {};
|
||||||
|
|||||||
@@ -1,35 +1,45 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
|
||||||
ReactElement,
|
ReactElement,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
|
||||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import { difference } from "../../shared/utils/object.utils";
|
|
||||||
import { isEmpty, isNil, map } from "lodash";
|
import { isEmpty, isNil, map } from "lodash";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
import type { AcquisitionPanelProps } from "../../types";
|
import type { AcquisitionPanelProps } from "../../types";
|
||||||
|
|
||||||
|
interface HubData {
|
||||||
|
hub_url: string;
|
||||||
|
identity: { name: string };
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirDCPPSearchResult {
|
||||||
|
id: string;
|
||||||
|
dupe?: unknown;
|
||||||
|
type: { id: string; str: string };
|
||||||
|
name: string;
|
||||||
|
slots: { total: number; free: number };
|
||||||
|
users: { user: { nicks: string; flags: string[] } };
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const AcquisitionPanel = (
|
export const AcquisitionPanel = (
|
||||||
props: AcquisitionPanelProps,
|
props: AcquisitionPanelProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
const socketRef = useRef<Socket>();
|
const socketRef = useRef<Socket | undefined>(undefined);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [dcppQuery, setDcppQuery] = useState({});
|
const [dcppQuery, setDcppQuery] = useState({});
|
||||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
|
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<AirDCPPSearchResult[]>([]);
|
||||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
|
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<{ id?: string; owner?: string; expires_in?: number }>({});
|
||||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
|
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<{ query?: { pattern: string; extensions: string[]; file_type: string } }>({});
|
||||||
|
|
||||||
const { comicObjectId } = props;
|
const { comicObjectId } = props;
|
||||||
const issueName = props.query.issue.name || "";
|
const issueName = props.query.issue.name || "";
|
||||||
@@ -134,13 +144,13 @@ export const AcquisitionPanel = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const download = async (
|
const download = async (
|
||||||
searchInstanceId: Number,
|
searchInstanceId: string | number,
|
||||||
resultId: String,
|
resultId: string,
|
||||||
comicObjectId: String,
|
comicObjectId: string,
|
||||||
name: String,
|
name: string,
|
||||||
size: Number,
|
size: number,
|
||||||
type: any,
|
type: unknown,
|
||||||
config: any,
|
config: Record<string, unknown>,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
socketRef.current?.emit(
|
socketRef.current?.emit(
|
||||||
"call",
|
"call",
|
||||||
@@ -160,7 +170,7 @@ export const AcquisitionPanel = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDCPPSearchResults = async (searchQuery) => {
|
const getDCPPSearchResults = async (searchQuery: { issueName: string }) => {
|
||||||
const manualQuery = {
|
const manualQuery = {
|
||||||
query: {
|
query: {
|
||||||
pattern: `${searchQuery.issueName}`,
|
pattern: `${searchQuery.issueName}`,
|
||||||
@@ -249,7 +259,7 @@ export const AcquisitionPanel = (
|
|||||||
<dl>
|
<dl>
|
||||||
<dt>
|
<dt>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
{hubs?.data.map((value, idx: string) => (
|
{hubs?.data.map((value: HubData, idx: number) => (
|
||||||
<span className="tag is-warning" key={idx}>
|
<span className="tag is-warning" key={idx}>
|
||||||
{value.identity.name}
|
{value.identity.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -260,19 +270,19 @@ export const AcquisitionPanel = (
|
|||||||
<dt>
|
<dt>
|
||||||
Query:
|
Query:
|
||||||
<span className="has-text-weight-semibold">
|
<span className="has-text-weight-semibold">
|
||||||
{airDCPPSearchInfo.query.pattern}
|
{airDCPPSearchInfo.query?.pattern}
|
||||||
</span>
|
</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
Extensions:
|
Extensions:
|
||||||
<span className="has-text-weight-semibold">
|
<span className="has-text-weight-semibold">
|
||||||
{airDCPPSearchInfo.query.extensions.join(", ")}
|
{airDCPPSearchInfo.query?.extensions.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
<dd>
|
<dd>
|
||||||
File type:
|
File type:
|
||||||
<span className="has-text-weight-semibold">
|
<span className="has-text-weight-semibold">
|
||||||
{airDCPPSearchInfo.query.file_type}
|
{airDCPPSearchInfo.query?.file_type}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -378,7 +388,7 @@ export const AcquisitionPanel = (
|
|||||||
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
download(
|
download(
|
||||||
airDCPPSearchInstance.id,
|
airDCPPSearchInstance.id ?? "",
|
||||||
id,
|
id,
|
||||||
comicObjectId,
|
comicObjectId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import Select from "react-select";
|
import Select, { StylesConfig, SingleValue } from "react-select";
|
||||||
|
import { ActionOption } from "../actionMenuConfig";
|
||||||
|
|
||||||
export const Menu = (props): ReactElement => {
|
interface MenuConfiguration {
|
||||||
|
filteredActionOptions: ActionOption[];
|
||||||
|
customStyles: StylesConfig<ActionOption, false>;
|
||||||
|
handleActionSelection: (action: SingleValue<ActionOption>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuProps {
|
||||||
|
data?: unknown;
|
||||||
|
handlers?: {
|
||||||
|
setSlidingPanelContentId: (id: string) => void;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
};
|
||||||
|
configuration: MenuConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Menu = (props: MenuProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
filteredActionOptions,
|
filteredActionOptions,
|
||||||
customStyles,
|
customStyles,
|
||||||
handleActionSelection,
|
handleActionSelection,
|
||||||
Placeholder,
|
|
||||||
} = props.configuration;
|
} = props.configuration;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select<ActionOption, false>
|
||||||
components={{ Placeholder }}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||||
<div className="w-6 h-6">
|
<div className="w-6 h-6">
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import dayjs from "dayjs";
|
|||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
import { DownloadProgressTick } from "./DownloadProgressTick";
|
import { DownloadProgressTick } from "./DownloadProgressTick";
|
||||||
export const AirDCPPBundles = (props) => {
|
|
||||||
|
interface BundleData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
target: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirDCPPBundlesProps {
|
||||||
|
data: BundleData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AirDCPPBundles = (props: AirDCPPBundlesProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto w-fit mt-6">
|
<div className="overflow-x-auto w-fit mt-6">
|
||||||
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
|
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
|
||||||
|
|||||||
@@ -2,41 +2,52 @@ import React, { ReactElement, useCallback, useState } from "react";
|
|||||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||||
import Creatable from "react-select/creatable";
|
import Creatable from "react-select/creatable";
|
||||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||||
|
|
||||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||||
|
|
||||||
interface AsyncSelectPaginateProps {
|
export interface AsyncSelectPaginateProps {
|
||||||
metronResource: string;
|
metronResource?: string;
|
||||||
placeholder?: string;
|
placeholder?: string | React.ReactNode;
|
||||||
value?: object;
|
value?: object;
|
||||||
onChange?(...args: unknown[]): unknown;
|
onChange?(...args: unknown[]): unknown;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdditionalType {
|
||||||
|
page: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||||
const [value, setValue] = useState(null);
|
|
||||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback((query, loadedOptions, { page }) => {
|
const loadData = useCallback(async (
|
||||||
|
query: string,
|
||||||
|
_loadedOptions: unknown,
|
||||||
|
additional?: AdditionalType
|
||||||
|
) => {
|
||||||
|
const page = additional?.page ?? 1;
|
||||||
return fetchMetronResource({
|
return fetchMetronResource({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
resource: props.metronResource,
|
resource: props.metronResource || "",
|
||||||
query: {
|
query: {
|
||||||
name: query,
|
name: query,
|
||||||
page,
|
page,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, []);
|
}, [props.metronResource]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreatableAsyncPaginate
|
<CreatableAsyncPaginate
|
||||||
SelectComponent={Creatable}
|
|
||||||
debounceTimeout={200}
|
debounceTimeout={200}
|
||||||
isDisabled={isAddingInProgress}
|
isDisabled={isAddingInProgress}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
loadOptions={loadData}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
loadOptions={loadData as any}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
// onCreateOption={onCreateOption}
|
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
// cacheUniqs={[cacheUniq]}
|
|
||||||
additional={{
|
additional={{
|
||||||
page: 1,
|
page: 1,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
|
|
||||||
// Hide "match on Comic Vine" when there are no raw file details — matching
|
// Hide "match on Comic Vine" when there are no raw file details — matching
|
||||||
// requires file metadata to seed the search query.
|
// requires file metadata to seed the search query.
|
||||||
const Placeholder = components.Placeholder;
|
const filteredActionOptions: ActionOption[] = actionOptions.filter((item) => {
|
||||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
|
||||||
if (isUndefined(rawFileDetails)) {
|
if (isUndefined(rawFileDetails)) {
|
||||||
return item.value !== "match-on-comic-vine";
|
return item.value !== "match-on-comic-vine";
|
||||||
}
|
}
|
||||||
return item;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleActionSelection = (action: ActionOption) => {
|
const handleActionSelection = (action: ActionOption | null) => {
|
||||||
|
if (!action) return;
|
||||||
switch (action.value) {
|
switch (action.value) {
|
||||||
case "match-on-comic-vine":
|
case "match-on-comic-vine":
|
||||||
openDrawerWithCVMatches();
|
openDrawerWithCVMatches();
|
||||||
@@ -190,7 +190,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
|||||||
filteredActionOptions,
|
filteredActionOptions,
|
||||||
customStyles,
|
customStyles,
|
||||||
handleActionSelection,
|
handleActionSelection,
|
||||||
Placeholder,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import type { ComicVineDetailsProps } from "../../types";
|
|||||||
|
|
||||||
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||||
const { data, updatedAt } = props;
|
const { data, updatedAt } = props;
|
||||||
|
|
||||||
|
if (!data || !data.volumeInformation) {
|
||||||
|
return <div className="text-slate-500 dark:text-gray-400">No ComicVine data available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedIssueType = data.volumeInformation.description
|
||||||
|
? detectIssueTypes(data.volumeInformation.description)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-slate-500 dark:text-gray-400">
|
<div className="text-slate-500 dark:text-gray-400">
|
||||||
<div className="">
|
<div className="">
|
||||||
@@ -15,10 +24,9 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
|||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<div className="min-w-fit">
|
<div className="min-w-fit">
|
||||||
<Card
|
<Card
|
||||||
imageUrl={data.volumeInformation.image.thumb_url}
|
imageUrl={data.volumeInformation.image?.thumb_url}
|
||||||
orientation={"cover-only"}
|
orientation={"cover-only"}
|
||||||
hasDetails={false}
|
hasDetails={false}
|
||||||
// cardContainerStyle={{ maxWidth: 200 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -40,7 +48,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
|||||||
<div className="text-md">ComicVine Metadata</div>
|
<div className="text-md">ComicVine Metadata</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Last scraped on{" "}
|
Last scraped on{" "}
|
||||||
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
{updatedAt ? dayjs(updatedAt).format("MMM D YYYY [at] h:mm a") : "Unknown"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
ComicVine Issue ID
|
ComicVine Issue ID
|
||||||
@@ -52,7 +60,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
|||||||
{/* Publisher details */}
|
{/* Publisher details */}
|
||||||
<div className="ml-8">
|
<div className="ml-8">
|
||||||
Published by{" "}
|
Published by{" "}
|
||||||
<span>{data.volumeInformation.publisher.name}</span>
|
<span>{data.volumeInformation.publisher?.name}</span>
|
||||||
<div>
|
<div>
|
||||||
Total issues in this volume{" "}
|
Total issues in this volume{" "}
|
||||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||||
@@ -68,16 +76,11 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
|||||||
<span>{data.issue_number}</span>
|
<span>{data.issue_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isUndefined(
|
{!isUndefined(detectedIssueType) ? (
|
||||||
detectIssueTypes(data.volumeInformation.description),
|
|
||||||
) ? (
|
|
||||||
<div>
|
<div>
|
||||||
<span>Detected Type</span>
|
<span>Detected Type</span>
|
||||||
<span>
|
<span>
|
||||||
{
|
{detectedIssueType.displayName}
|
||||||
detectIssueTypes(data.volumeInformation.description)
|
|
||||||
.displayName
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : data.resource_type ? (
|
) : data.resource_type ? (
|
||||||
@@ -92,6 +95,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mt-3 w-3/4">
|
<div className="mt-3 w-3/4">
|
||||||
{!isEmpty(data.description) &&
|
{!isEmpty(data.description) &&
|
||||||
|
data.description &&
|
||||||
convert(data.description, {
|
convert(data.description, {
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p"],
|
selectors: ["p"],
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import Collapsible from "react-collapsible";
|
import { ValidationErrors } from "final-form";
|
||||||
import { fetchComicVineMatches } from "../../actions/fileops.actions";
|
import { fetchComicVineMatches } from "../../actions/fileops.actions";
|
||||||
|
|
||||||
|
interface ComicVineSearchFormProps {
|
||||||
|
rawFileDetails?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFormValues {
|
||||||
|
issueName?: string;
|
||||||
|
issueNumber?: string;
|
||||||
|
issueYear?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for performing search against ComicVine
|
* Component for performing search against ComicVine
|
||||||
*
|
*
|
||||||
@@ -12,8 +22,8 @@ import { fetchComicVineMatches } from "../../actions/fileops.actions";
|
|||||||
* <ComicVineSearchForm data={rawFileDetails} />
|
* <ComicVineSearchForm data={rawFileDetails} />
|
||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
export const ComicVineSearchForm = (data) => {
|
export const ComicVineSearchForm = (props: ComicVineSearchFormProps) => {
|
||||||
const onSubmit = useCallback((value) => {
|
const onSubmit = useCallback((value: SearchFormValues) => {
|
||||||
const userInititatedQuery = {
|
const userInititatedQuery = {
|
||||||
inferredIssueDetails: {
|
inferredIssueDetails: {
|
||||||
name: value.issueName,
|
name: value.issueName,
|
||||||
@@ -24,8 +34,8 @@ export const ComicVineSearchForm = (data) => {
|
|||||||
};
|
};
|
||||||
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
||||||
}, []);
|
}, []);
|
||||||
const validate = () => {
|
const validate = (_values: SearchFormValues): ValidationErrors | undefined => {
|
||||||
return true;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MyForm = () => (
|
const MyForm = () => (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type DownloadTickData = {
|
|||||||
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||||
bundleId,
|
bundleId,
|
||||||
}): ReactElement | null => {
|
}): ReactElement | null => {
|
||||||
const socketRef = useRef<Socket>();
|
const socketRef = useRef<Socket | undefined>(undefined);
|
||||||
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = useStore.getState().getSocket("manual");
|
const socket = useStore.getState().getSocket("manual");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||||
import { TorrentDownloads } from "./TorrentDownloads";
|
import { TorrentDownloads, TorrentData } from "./TorrentDownloads";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +32,7 @@ export interface TorrentDetails {
|
|||||||
export const DownloadsPanel = (): ReactElement | null => {
|
export const DownloadsPanel = (): ReactElement | null => {
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||||
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
|
const [torrentDetails, setTorrentDetails] = useState<TorrentData[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||||
"directconnect",
|
"directconnect",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,12 +7,37 @@ import axios from "axios";
|
|||||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||||
import type { MatchResultProps } from "../../types";
|
import type { MatchResultProps } from "../../types";
|
||||||
|
|
||||||
const handleBrokenImage = (e) => {
|
const handleBrokenImage = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
|
e.currentTarget.src = "http://localhost:3050/dist/img/noimage.svg";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ComicVineMatch {
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
score: string | number;
|
||||||
|
issue_number: string | number;
|
||||||
|
cover_date: string;
|
||||||
|
image: {
|
||||||
|
thumb_url: string;
|
||||||
|
};
|
||||||
|
volume: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
volumeInformation: {
|
||||||
|
results: {
|
||||||
|
image: {
|
||||||
|
icon_url: string;
|
||||||
|
};
|
||||||
|
count_of_issues: number;
|
||||||
|
publisher: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const MatchResult = (props: MatchResultProps) => {
|
export const MatchResult = (props: MatchResultProps) => {
|
||||||
const applyCVMatch = async (match, comicObjectId) => {
|
const applyCVMatch = async (match: ComicVineMatch, comicObjectId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.request({
|
const response = await axios.request({
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<ComicVineSearchForm data={rawFileDetails} />
|
<ComicVineSearchForm rawFileDetails={rawFileDetails} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
<div className="border-slate-500 border rounded-lg p-2 mt-3">
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
import React, { ReactElement, Suspense, useState } from "react";
|
import React, { ReactElement, Suspense, useState } from "react";
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
export const TabControls = (props): ReactElement => {
|
interface TabItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
content: React.ReactNode;
|
||||||
|
shouldShow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabControlsProps {
|
||||||
|
filteredTabs: TabItem[];
|
||||||
|
downloadCount: number;
|
||||||
|
activeTab?: number;
|
||||||
|
setActiveTab?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabControls = (props: TabControlsProps): ReactElement => {
|
||||||
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
||||||
const [active, setActive] = useState(filteredTabs[0].id);
|
const [active, setActive] = useState(filteredTabs[0].id);
|
||||||
|
|
||||||
// Use controlled state if provided, otherwise use internal state
|
// Use controlled state if provided, otherwise use internal state
|
||||||
const currentActive = activeTab !== undefined ? activeTab : active;
|
const currentActive = activeTab !== undefined ? activeTab : active;
|
||||||
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
|
const handleSetActive = (id: number) => {
|
||||||
|
if (setActiveTab) {
|
||||||
|
setActiveTab(id);
|
||||||
|
} else {
|
||||||
|
setActive(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex gap-6" aria-label="Tabs">
|
<nav className="flex gap-6" aria-label="Tabs">
|
||||||
{filteredTabs.map(({ id, name, icon }) => (
|
{filteredTabs.map(({ id, name, icon }: TabItem) => (
|
||||||
<a
|
<a
|
||||||
key={id}
|
key={id}
|
||||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||||
@@ -48,7 +69,7 @@ export const TabControls = (props): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{filteredTabs.map(({ id, content }) => (
|
{filteredTabs.map(({ id, content }: TabItem) => (
|
||||||
<React.Fragment key={id}>
|
<React.Fragment key={id}>
|
||||||
{currentActive === id ? content : null}
|
{currentActive === id ? content : null}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
|||||||
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
|
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
|
||||||
|
|
||||||
// sliding panel init
|
// sliding panel init
|
||||||
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
|
const contentForSlidingPanel: Record<string, { content: () => React.ReactElement }> = {
|
||||||
imageAnalysis: {
|
imageAnalysis: {
|
||||||
content: () => {
|
content: () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,48 @@ import React from "react";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
export const TorrentDownloads = (props) => {
|
interface TorrentInfo {
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
added_on: number;
|
||||||
|
progress: number;
|
||||||
|
downloaded: number;
|
||||||
|
uploaded: number;
|
||||||
|
trackers_count: number;
|
||||||
|
total_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TorrentData {
|
||||||
|
torrent?: TorrentInfo;
|
||||||
|
// Support direct TorrentDetails format from socket events
|
||||||
|
infoHash?: string;
|
||||||
|
downloadSpeed?: number;
|
||||||
|
uploadSpeed?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TorrentDownloadsProps {
|
||||||
|
data: TorrentData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { TorrentData };
|
||||||
|
|
||||||
|
export const TorrentDownloads = (props: TorrentDownloadsProps) => {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data.map(({ torrent }) => {
|
{data.map((item: TorrentData, index: number) => {
|
||||||
|
// Support both wrapped format (item.torrent) and direct format
|
||||||
|
const torrent: TorrentInfo = item.torrent || {
|
||||||
|
name: item.name || 'Unknown',
|
||||||
|
hash: item.infoHash || '',
|
||||||
|
added_on: 0,
|
||||||
|
progress: (item as any).progress || 0,
|
||||||
|
downloaded: 0,
|
||||||
|
uploaded: 0,
|
||||||
|
trackers_count: 0,
|
||||||
|
total_size: 0,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<dl className="mt-5 dark:text-slate-200 text-slate-600">
|
<dl className="mt-5 dark:text-slate-200 text-slate-600">
|
||||||
<dt className="text-lg">{torrent.name}</dt>
|
<dt className="text-lg">{torrent.name}</dt>
|
||||||
|
|||||||
@@ -10,7 +10,31 @@ import { isEmpty, isNil } from "lodash";
|
|||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
export const TorrentSearchPanel = (props) => {
|
interface TorrentSearchPanelProps {
|
||||||
|
issueName: string;
|
||||||
|
comicObjectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFormValues {
|
||||||
|
issueName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TorrentResult {
|
||||||
|
fileName: string;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
size: number;
|
||||||
|
files: number;
|
||||||
|
indexer: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TorrentDownloadPayload {
|
||||||
|
comicObjectId: string;
|
||||||
|
torrentToDownload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TorrentSearchPanel = (props: TorrentSearchPanelProps) => {
|
||||||
const { issueName, comicObjectId } = props;
|
const { issueName, comicObjectId } = props;
|
||||||
// Initialize searchTerm with issueName from props
|
// Initialize searchTerm with issueName from props
|
||||||
const [searchTerm, setSearchTerm] = useState({ issueName });
|
const [searchTerm, setSearchTerm] = useState({ issueName });
|
||||||
@@ -40,19 +64,19 @@ export const TorrentSearchPanel = (props) => {
|
|||||||
enabled: !isNil(searchTerm.issueName) && searchTerm.issueName.trim() !== "", // Make sure searchTerm is not empty
|
enabled: !isNil(searchTerm.issueName) && searchTerm.issueName.trim() !== "", // Make sure searchTerm is not empty
|
||||||
});
|
});
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (newTorrent) =>
|
mutationFn: async (newTorrent: TorrentDownloadPayload) =>
|
||||||
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
|
||||||
onSuccess: async (data) => {
|
onSuccess: async () => {
|
||||||
// Torrent added successfully
|
// Torrent added successfully
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const searchIndexer = (values) => {
|
const searchIndexer = (values: SearchFormValues) => {
|
||||||
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
|
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
|
||||||
};
|
};
|
||||||
const downloadTorrent = (evt) => {
|
const downloadTorrent = (downloadUrl: string) => {
|
||||||
const newTorrent = {
|
const newTorrent: TorrentDownloadPayload = {
|
||||||
comicObjectId,
|
comicObjectId,
|
||||||
torrentToDownload: evt,
|
torrentToDownload: downloadUrl,
|
||||||
};
|
};
|
||||||
mutation.mutate(newTorrent);
|
mutation.mutate(newTorrent);
|
||||||
};
|
};
|
||||||
@@ -125,7 +149,7 @@ export const TorrentSearchPanel = (props) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||||
{data?.data.map((result, idx) => (
|
{data?.data.map((result: TorrentResult, idx: number) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
|
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
|
||||||
<p>{ellipsize(result.fileName, 90)}</p>
|
<p>{ellipsize(result.fileName, 90)}</p>
|
||||||
|
|||||||
@@ -1,59 +1,49 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from "react";
|
import React, { ReactElement, useEffect, useState } from "react";
|
||||||
import { getTransfers } from "../../actions/airdcpp.actions";
|
import { isEmpty, isNil } from "lodash";
|
||||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
|
||||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import type { DownloadsProps } from "../../types";
|
import type { DownloadsProps } from "../../types";
|
||||||
|
import { useStore } from "../../store";
|
||||||
|
|
||||||
export const Downloads = (props: DownloadsProps): ReactElement => {
|
interface BundleData {
|
||||||
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
rawFileDetails?: Record<string, unknown>;
|
||||||
const {
|
inferredMetadata?: Record<string, unknown>;
|
||||||
airDCPPState: { settings, socket },
|
acquisition?: {
|
||||||
} = airDCPPConfiguration;
|
directconnect?: {
|
||||||
// const dispatch = useDispatch();
|
downloads?: Array<{
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: { str: string };
|
||||||
|
bundleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
sourcedMetadata?: {
|
||||||
|
locg?: unknown;
|
||||||
|
comicvine?: unknown;
|
||||||
|
};
|
||||||
|
issueName?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// const airDCPPTransfers = useSelector(
|
export const Downloads = (_props: DownloadsProps): ReactElement => {
|
||||||
// (state: RootState) => state.airdcpp.transfers,
|
// Using Zustand store for socket management
|
||||||
// );
|
const getSocket = useStore((state) => state.getSocket);
|
||||||
// const issueBundles = useSelector(
|
|
||||||
// (state: RootState) => state.airdcpp.issue_bundles,
|
const [bundles, setBundles] = useState<BundleData[]>([]);
|
||||||
// );
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [bundles, setBundles] = useState([]);
|
|
||||||
// Make the call to get all transfers from AirDC++
|
// Initialize socket connection and load data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUndefined(socket) && !isEmpty(settings)) {
|
const socket = getSocket();
|
||||||
dispatch(
|
if (socket) {
|
||||||
getTransfers(socket, {
|
// Socket is connected, we could fetch transfers here
|
||||||
username: `${settings.directConnect.client.host.username}`,
|
// For now, just set loading to false since we don't have direct access to Redux state
|
||||||
password: `${settings.directConnect.client.host.password}`,
|
setIsLoading(false);
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [getSocket]);
|
||||||
|
|
||||||
useEffect(() => {
|
return !isNil(bundles) && bundles.length > 0 ? (
|
||||||
if (!isUndefined(issueBundles)) {
|
|
||||||
const foo = issueBundles.data.map((bundle) => {
|
|
||||||
const {
|
|
||||||
rawFileDetails,
|
|
||||||
inferredMetadata,
|
|
||||||
acquisition: {
|
|
||||||
directconnect: { downloads },
|
|
||||||
},
|
|
||||||
sourcedMetadata: { locg, comicvine },
|
|
||||||
} = bundle;
|
|
||||||
const { issueName, url } = determineCoverFile({
|
|
||||||
rawFileDetails,
|
|
||||||
comicvine,
|
|
||||||
locg,
|
|
||||||
});
|
|
||||||
return { ...bundle, issueName, url };
|
|
||||||
});
|
|
||||||
setBundles(foo);
|
|
||||||
}
|
|
||||||
}, [issueBundles]);
|
|
||||||
|
|
||||||
return !isNil(bundles) ? (
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h1 className="title">Downloads</h1>
|
<h1 className="title">Downloads</h1>
|
||||||
@@ -84,16 +74,16 @@ export const Downloads = (props: DownloadsProps): ReactElement => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{bundle.acquisition.directconnect.downloads.map(
|
{bundle.acquisition?.directconnect?.downloads?.map(
|
||||||
(bundle, idx) => {
|
(download, idx: number) => {
|
||||||
return (
|
return (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td>{bundle.name}</td>
|
<td>{download.name}</td>
|
||||||
<td>{bundle.size}</td>
|
<td>{download.size}</td>
|
||||||
<td>{bundle.type.str}</td>
|
<td>{download.type.str}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="tag is-warning">
|
<span className="tag is-warning">
|
||||||
{bundle.bundleId}
|
{download.bundleId}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import { searchIssue } from "../../actions/fileops.actions";
|
|||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import type { GlobalSearchBarProps } from "../../types";
|
import type { GlobalSearchBarProps } from "../../types";
|
||||||
|
|
||||||
|
interface AppRootState {
|
||||||
|
fileOps: {
|
||||||
|
librarySearchResultsFormatted: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const SearchBar = (data: GlobalSearchBarProps): ReactElement => {
|
export const SearchBar = (data: GlobalSearchBarProps): ReactElement => {
|
||||||
const dispatch = useDispatch();
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const dispatch = useDispatch<any>();
|
||||||
const searchResults = useSelector(
|
const searchResults = useSelector(
|
||||||
(state: RootState) => state.fileOps.librarySearchResultsFormatted,
|
(state: AppRootState) => state.fileOps.librarySearchResultsFormatted,
|
||||||
);
|
);
|
||||||
|
|
||||||
const performSearch = useCallback(
|
const performSearch = useCallback(
|
||||||
|
|||||||
@@ -18,12 +18,42 @@ import { Link } from "react-router-dom";
|
|||||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||||
import type { LibraryGridProps } from "../../types";
|
import type { LibraryGridProps } from "../../types";
|
||||||
|
|
||||||
|
interface ComicDoc {
|
||||||
|
_id: string;
|
||||||
|
rawFileDetails?: {
|
||||||
|
cover?: {
|
||||||
|
filePath: string;
|
||||||
|
};
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
sourcedMetadata?: {
|
||||||
|
comicvine?: {
|
||||||
|
image?: {
|
||||||
|
small_url?: string;
|
||||||
|
};
|
||||||
|
name?: string;
|
||||||
|
volumeInformation?: {
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRootState {
|
||||||
|
fileOps: {
|
||||||
|
recentComics: {
|
||||||
|
docs: ComicDoc[];
|
||||||
|
totalDocs: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
||||||
const data = useSelector(
|
const data = useSelector(
|
||||||
(state: RootState) => state.fileOps.recentComics.docs,
|
(state: AppRootState) => state.fileOps.recentComics.docs,
|
||||||
);
|
);
|
||||||
const pageTotal = useSelector(
|
const pageTotal = useSelector(
|
||||||
(state: RootState) => state.fileOps.recentComics.totalDocs,
|
(state: AppRootState) => state.fileOps.recentComics.totalDocs,
|
||||||
);
|
);
|
||||||
const breakpointColumnsObj = {
|
const breakpointColumnsObj = {
|
||||||
default: 5,
|
default: 5,
|
||||||
@@ -42,20 +72,20 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
|||||||
className="my-masonry-grid"
|
className="my-masonry-grid"
|
||||||
columnClassName="my-masonry-grid_column"
|
columnClassName="my-masonry-grid_column"
|
||||||
>
|
>
|
||||||
{data.map(({ _id, rawFileDetails, sourcedMetadata }) => {
|
{data.map(({ _id, rawFileDetails, sourcedMetadata }: ComicDoc) => {
|
||||||
let imagePath = "";
|
let imagePath = "";
|
||||||
let comicName = "";
|
let comicName = "";
|
||||||
if (!isEmpty(rawFileDetails.cover)) {
|
if (rawFileDetails && !isEmpty(rawFileDetails.cover)) {
|
||||||
const encodedFilePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
`${LIBRARY_SERVICE_HOST}/${removeLeadingPeriod(
|
`${LIBRARY_SERVICE_HOST}/${removeLeadingPeriod(
|
||||||
rawFileDetails.cover.filePath,
|
rawFileDetails.cover?.filePath || '',
|
||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
imagePath = escapePoundSymbol(encodedFilePath);
|
imagePath = escapePoundSymbol(encodedFilePath);
|
||||||
comicName = rawFileDetails.name;
|
comicName = rawFileDetails.name || '';
|
||||||
} else if (!isNil(sourcedMetadata)) {
|
} else if (!isNil(sourcedMetadata) && sourcedMetadata.comicvine?.image?.small_url) {
|
||||||
imagePath = sourcedMetadata.comicvine.image.small_url;
|
imagePath = sourcedMetadata.comicvine.image.small_url;
|
||||||
comicName = sourcedMetadata.comicvine.name;
|
comicName = sourcedMetadata.comicvine?.name || '';
|
||||||
}
|
}
|
||||||
const titleElement = (
|
const titleElement = (
|
||||||
<Link to={"/comic/details/" + _id}>
|
<Link to={"/comic/details/" + _id}>
|
||||||
@@ -71,7 +101,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
|||||||
title={comicName ? titleElement : null}
|
title={comicName ? titleElement : null}
|
||||||
>
|
>
|
||||||
<div className="content is-flex is-flex-direction-row">
|
<div className="content is-flex is-flex-direction-row">
|
||||||
{!isEmpty(sourcedMetadata.comicvine) && (
|
{sourcedMetadata && !isEmpty(sourcedMetadata.comicvine) && (
|
||||||
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
<span className="icon cv-icon is-small inline-block w-6 h-6 md:w-7 md:h-7 flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src="/src/client/assets/img/cvlogo.svg"
|
src="/src/client/assets/img/cvlogo.svg"
|
||||||
@@ -85,7 +115,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
|||||||
<i className="fas fa-adjust" />
|
<i className="fas fa-adjust" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isUndefined(sourcedMetadata.comicvine.volumeInformation) &&
|
{sourcedMetadata?.comicvine?.volumeInformation?.description &&
|
||||||
!isEmpty(
|
!isEmpty(
|
||||||
detectIssueTypes(
|
detectIssueTypes(
|
||||||
sourcedMetadata.comicvine.volumeInformation.description,
|
sourcedMetadata.comicvine.volumeInformation.description,
|
||||||
@@ -94,8 +124,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
|
|||||||
<span className="tag is-warning ml-1">
|
<span className="tag is-warning ml-1">
|
||||||
{
|
{
|
||||||
detectIssueTypes(
|
detectIssueTypes(
|
||||||
sourcedMetadata.comicvine.volumeInformation
|
sourcedMetadata.comicvine.volumeInformation.description || '',
|
||||||
.description,
|
|
||||||
).displayName
|
).displayName
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import PropTypes from "prop-types";
|
|||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export const SearchBar = (props): ReactElement => {
|
interface SearchBarProps {
|
||||||
|
searchHandler: (values: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchBar = (props: SearchBarProps): ReactElement => {
|
||||||
const { searchHandler } = props;
|
const { searchHandler } = props;
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
import React, { ReactElement, useEffect, useMemo, useState } from "react";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
import type { CellContext } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
interface PullListComic {
|
||||||
|
issue: {
|
||||||
|
cover: string;
|
||||||
|
name: string;
|
||||||
|
publisher: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
pulls: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const PullList = (): ReactElement => {
|
export const PullList = (): ReactElement => {
|
||||||
// const pullListComics = useSelector(
|
// Placeholder for pull list comics - would come from API/store
|
||||||
// (state: RootState) => state.comicInfo.pullList,
|
const [pullListComics, setPullListComics] = useState<PullListComic[] | null>(null);
|
||||||
// );
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// TODO: Implement pull list fetching
|
||||||
// dispatch(
|
// dispatch(
|
||||||
// getWeeklyPullList({
|
// getWeeklyPullList({
|
||||||
// startDate: "2023-7-28",
|
// startDate: "2023-7-28",
|
||||||
@@ -31,7 +43,7 @@ export const PullList = (): ReactElement => {
|
|||||||
id: "comicDetails",
|
id: "comicDetails",
|
||||||
minWidth: 450,
|
minWidth: 450,
|
||||||
accessorKey: "issue",
|
accessorKey: "issue",
|
||||||
cell: (row) => {
|
cell: (row: CellContext<PullListComic, PullListComic["issue"]>) => {
|
||||||
const item = row.getValue();
|
const item = row.getValue();
|
||||||
return (
|
return (
|
||||||
<div className="columns">
|
<div className="columns">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { ReactElement, useState } from "react";
|
import React, { ReactElement, useState } from "react";
|
||||||
import { isNil, isEmpty, isUndefined } from "lodash";
|
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
|
||||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||||
import { Form, Field } from "react-final-form";
|
import { Form, Field } from "react-final-form";
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
@@ -16,17 +15,35 @@ import {
|
|||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
} from "../../constants/endpoints";
|
} from "../../constants/endpoints";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { SearchPageProps } from "../../types";
|
import type { SearchPageProps, ComicVineSearchResult } from "../../types";
|
||||||
|
|
||||||
|
interface ComicData {
|
||||||
|
id: number;
|
||||||
|
api_detail_url: string;
|
||||||
|
image: { small_url: string; thumb_url?: string };
|
||||||
|
cover_date?: string;
|
||||||
|
issue_number?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
volume?: { name: string; api_detail_url: string };
|
||||||
|
start_year?: string;
|
||||||
|
count_of_issues?: number;
|
||||||
|
publisher?: { name: string };
|
||||||
|
resource_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Search = ({}: SearchPageProps): ReactElement => {
|
export const Search = ({}: SearchPageProps): ReactElement => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const formData = {
|
const formData = {
|
||||||
search: "",
|
search: "",
|
||||||
};
|
};
|
||||||
const [comicVineMetadata, setComicVineMetadata] = useState({});
|
const [comicVineMetadata, setComicVineMetadata] = useState<{
|
||||||
|
sourceName?: string;
|
||||||
|
comicData?: ComicData;
|
||||||
|
}>({});
|
||||||
const [selectedResource, setSelectedResource] = useState("volume");
|
const [selectedResource, setSelectedResource] = useState("volume");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleResourceChange = (value) => {
|
const handleResourceChange = (value: string) => {
|
||||||
setSelectedResource(value);
|
setSelectedResource(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +79,11 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
comicObject,
|
comicObject,
|
||||||
markEntireVolumeWanted,
|
markEntireVolumeWanted,
|
||||||
resourceType,
|
resourceType,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
comicObject: any;
|
||||||
|
markEntireVolumeWanted: boolean;
|
||||||
|
resourceType: string;
|
||||||
}) => {
|
}) => {
|
||||||
let volumeInformation = {};
|
let volumeInformation = {};
|
||||||
let issues = [];
|
let issues = [];
|
||||||
@@ -142,14 +164,14 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const addToLibrary = (sourceName: string, comicData) =>
|
const addToLibrary = (sourceName: string, comicData: ComicData) =>
|
||||||
setComicVineMetadata({ sourceName, comicData });
|
setComicVineMetadata({ sourceName, comicData });
|
||||||
|
|
||||||
const createDescriptionMarkup = (html) => {
|
const createDescriptionMarkup = (html: string) => {
|
||||||
return { __html: html };
|
return { __html: html };
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
const onSubmit = async (values: { search: string }) => {
|
||||||
const formData = { ...values, resource: selectedResource };
|
const formData = { ...values, resource: selectedResource };
|
||||||
try {
|
try {
|
||||||
mutate(formData);
|
mutate(formData);
|
||||||
@@ -269,7 +291,7 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{comicVineSearchResults.data.results.map((result) => {
|
{comicVineSearchResults?.data?.results?.map((result: ComicData) => {
|
||||||
return result.resource_type === "issue" ? (
|
return result.resource_type === "issue" ? (
|
||||||
<div
|
<div
|
||||||
key={result.id}
|
key={result.id}
|
||||||
@@ -286,8 +308,8 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-3/4">
|
<div className="w-3/4">
|
||||||
<div className="text-xl">
|
<div className="text-xl">
|
||||||
{!isEmpty(result.volume.name) ? (
|
{!isEmpty(result.volume?.name) ? (
|
||||||
result.volume.name
|
result.volume?.name
|
||||||
) : (
|
) : (
|
||||||
<span className="is-size-3">No Name</span>
|
<span className="is-size-3">No Name</span>
|
||||||
)}
|
)}
|
||||||
@@ -305,18 +327,18 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
{result.api_detail_url}
|
{result.api_detail_url}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{ellipsize(
|
{result.description ? ellipsize(
|
||||||
convert(result.description, {
|
convert(result.description, {
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p", "div"],
|
selectors: ["p", "div"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
320,
|
320,
|
||||||
)}
|
) : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
content={`This will add ${result.volume.name} to your wanted list.`}
|
content={`This will add ${result.volume?.name || 'this issue'} to your wanted list.`}
|
||||||
clickHandler={() =>
|
clickHandler={() =>
|
||||||
addToWantedList({
|
addToWantedList({
|
||||||
source: "comicvine",
|
source: "comicvine",
|
||||||
@@ -407,14 +429,14 @@ export const Search = ({}: SearchPageProps): ReactElement => {
|
|||||||
|
|
||||||
{/* description */}
|
{/* description */}
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{ellipsize(
|
{result.description ? ellipsize(
|
||||||
convert(result.description, {
|
convert(result.description, {
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p", "div"],
|
selectors: ["p", "div"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
320,
|
320,
|
||||||
)}
|
) : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ import { useDispatch, useSelector } from "react-redux";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getServiceStatus } from "../../actions/fileops.actions";
|
import { getServiceStatus } from "../../actions/fileops.actions";
|
||||||
|
|
||||||
|
interface AppRootState {
|
||||||
|
fileOps: {
|
||||||
|
libraryServiceStatus: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const ServiceStatuses = (): ReactElement => {
|
export const ServiceStatuses = (): ReactElement => {
|
||||||
const serviceStatus = useSelector(
|
const serviceStatus = useSelector(
|
||||||
(state: RootState) => state.fileOps.libraryServiceStatus,
|
(state: AppRootState) => state.fileOps.libraryServiceStatus,
|
||||||
);
|
);
|
||||||
const dispatch = useDispatch();
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const dispatch = useDispatch<any>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(getServiceStatus());
|
dispatch(getServiceStatus());
|
||||||
}, []);
|
}, [dispatch]);
|
||||||
return (
|
return (
|
||||||
<div className="is-clearfix">
|
<div className="is-clearfix">
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -38,16 +38,21 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
|||||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||||
});
|
});
|
||||||
|
|
||||||
let hubList: any[] = [];
|
interface HubOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hubList: HubOption[] = [];
|
||||||
if (!isNil(hubs)) {
|
if (!isNil(hubs)) {
|
||||||
hubList = hubs?.data.map(({ hub_url, identity }) => ({
|
hubList = hubs?.data.map(({ hub_url, identity }: { hub_url: string; identity: { name: string } }) => ({
|
||||||
value: hub_url,
|
value: hub_url,
|
||||||
label: identity.name,
|
label: identity.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values) =>
|
mutationFn: async (values: Record<string, unknown>) =>
|
||||||
await axios({
|
await axios({
|
||||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -69,13 +74,24 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validate = async (values) => {
|
const validate = async (values: Record<string, unknown>) => {
|
||||||
const errors = {};
|
const errors: Record<string, string> = {};
|
||||||
// Add any validation logic here if needed
|
// Add any validation logic here if needed
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectAdapter = ({ input, ...rest }) => {
|
interface SelectAdapterProps {
|
||||||
|
input: {
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectAdapter = ({ input, ...rest }: SelectAdapterProps) => {
|
||||||
return <Select {...input} {...rest} isClearable isMulti />;
|
return <Select {...input} {...rest} isClearable isMulti />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,7 +171,7 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||||
{settings?.data.directConnect?.client.hubs.map(
|
{settings?.data.directConnect?.client.hubs.map(
|
||||||
({ value, label }) => (
|
({ value, label }: HubOption) => (
|
||||||
<div key={value}>
|
<div key={value}>
|
||||||
<div>{label}</div>
|
<div>{label}</div>
|
||||||
<span className="is-size-7">{value}</span>
|
<span className="is-size-7">{value}</span>
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import React, { ReactElement } from "react";
|
import React, { ReactElement } from "react";
|
||||||
|
|
||||||
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
|
interface AirDCPPSessionInfo {
|
||||||
const { settings } = settingsObject;
|
_id: string;
|
||||||
|
system_info: {
|
||||||
|
client_version: string;
|
||||||
|
hostname: string;
|
||||||
|
platform: string;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
active_sessions: number;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirDCPPSettingsConfirmationProps {
|
||||||
|
settings: AirDCPPSessionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AirDCPPSettingsConfirmation = ({ settings }: AirDCPPSettingsConfirmationProps): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="flex items-center mt-10 mb-4">
|
<span className="flex items-center mt-10 mb-4">
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ export const AirDCPPSettingsForm = () => {
|
|||||||
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface HostConfig {
|
||||||
|
hostname: string;
|
||||||
|
port: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
protocol: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch session information
|
// Fetch session information
|
||||||
const fetchSessionInfo = (host) => {
|
const fetchSessionInfo = (host: HostConfig) => {
|
||||||
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +42,7 @@ export const AirDCPPSettingsForm = () => {
|
|||||||
|
|
||||||
// Handle setting update and subsequent AirDC++ initialization
|
// Handle setting update and subsequent AirDC++ initialization
|
||||||
const { mutate } = useMutation({
|
const { mutate } = useMutation({
|
||||||
mutationFn: (values) => {
|
mutationFn: (values: Record<string, unknown>) => {
|
||||||
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
settingsPayload: values,
|
settingsPayload: values,
|
||||||
settingsKey: "directConnect",
|
settingsKey: "directConnect",
|
||||||
@@ -50,12 +58,13 @@ export const AirDCPPSettingsForm = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteSettingsMutation = useMutation(() =>
|
const deleteSettingsMutation = useMutation({
|
||||||
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
mutationFn: () =>
|
||||||
settingsPayload: {},
|
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||||
settingsKey: "directConnect",
|
settingsPayload: {},
|
||||||
}),
|
settingsKey: "directConnect",
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import { Form, Field } from "react-final-form";
|
|||||||
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const ProwlarrSettingsForm = (props) => {
|
interface ProwlarrSettingsFormProps {
|
||||||
|
// Add props here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProwlarrSettingsForm = (_props: ProwlarrSettingsFormProps) => {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryFn: async (): any => {
|
queryFn: async () => {
|
||||||
return await axios({
|
return await axios({
|
||||||
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
|
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
|||||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const QbittorrentConnectionForm = (): ReactElement => {
|
export const QbittorrentConnectionForm = (): ReactElement | null => {
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
// fetch settings
|
// fetch settings
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
@@ -28,7 +28,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
|
|||||||
});
|
});
|
||||||
// Update action using a mutation
|
// Update action using a mutation
|
||||||
const { mutate } = useMutation({
|
const { mutate } = useMutation({
|
||||||
mutationFn: async (values) =>
|
mutationFn: async (values: Record<string, unknown>) =>
|
||||||
await axios({
|
await axios({
|
||||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -77,6 +77,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default QbittorrentConnectionForm;
|
export default QbittorrentConnectionForm;
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ import settingsObject from "../../constants/settings/settingsMenu.json";
|
|||||||
import { isUndefined, map } from "lodash";
|
import { isUndefined, map } from "lodash";
|
||||||
import type { SettingsProps } from "../../types";
|
import type { SettingsProps } from "../../types";
|
||||||
|
|
||||||
|
interface SettingsMenuItem {
|
||||||
|
id: string | number;
|
||||||
|
displayName: string;
|
||||||
|
children?: SettingsMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsCategory {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
displayName: string;
|
||||||
|
children?: SettingsMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export const Settings = (props: SettingsProps): ReactElement => {
|
export const Settings = (props: SettingsProps): ReactElement => {
|
||||||
const [active, setActive] = useState("gen-db");
|
const [active, setActive] = useState("gen-db");
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
@@ -62,70 +75,70 @@ export const Settings = (props: SettingsProps): ReactElement => {
|
|||||||
overflow-hidden"
|
overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-4 py-6 overflow-y-auto">
|
<div className="px-4 py-6 overflow-y-auto">
|
||||||
{map(settingsObject, (settingObject, idx) => (
|
{map(settingsObject as SettingsCategory[], (settingObject, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="mb-6 text-slate-700 dark:text-slate-300"
|
className="mb-6 text-slate-700 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
|
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
|
||||||
{settingObject.category.toUpperCase()}
|
{settingObject.category.toUpperCase()}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{!isUndefined(settingObject.children) && (
|
{!isUndefined(settingObject.children) && (
|
||||||
<ul>
|
<ul>
|
||||||
{map(settingObject.children, (item, idx) => {
|
{map(settingObject.children, (item: SettingsMenuItem, idx) => {
|
||||||
const isOpen = expanded[item.id];
|
const isOpen = expanded[String(item.id)];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={idx} className="mb-1">
|
<li key={idx} className="mb-1">
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleExpanded(item.id)}
|
onClick={() => toggleExpanded(String(item.id))}
|
||||||
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||||
item.id === active
|
String(item.id) === active
|
||||||
? "font-semibold text-blue-600 dark:text-blue-400"
|
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
|
||||||
<span
|
|
||||||
onClick={() => setActive(item.id.toString())}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
{item.displayName}
|
<span
|
||||||
</span>
|
onClick={() => setActive(String(item.id))}
|
||||||
{!isUndefined(item.children) && (
|
className="flex-1"
|
||||||
<span className="text-xs opacity-60">
|
>
|
||||||
{isOpen ? "−" : "+"}
|
{item.displayName}
|
||||||
</span>
|
</span>
|
||||||
|
{!isUndefined(item.children) && (
|
||||||
|
<span className="text-xs opacity-60">
|
||||||
|
{isOpen ? "−" : "+"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isUndefined(item.children) && isOpen && (
|
||||||
|
<ul className="pl-4 mt-1">
|
||||||
|
{map(item.children, (subItem: SettingsMenuItem) => (
|
||||||
|
<li key={String(subItem.id)} className="mb-1">
|
||||||
|
<a
|
||||||
|
onClick={() =>
|
||||||
|
setActive(String(subItem.id))
|
||||||
|
}
|
||||||
|
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
||||||
|
String(subItem.id) === active
|
||||||
|
? "font-semibold text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subItem.displayName}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
|
);
|
||||||
{!isUndefined(item.children) && isOpen && (
|
})}
|
||||||
<ul className="pl-4 mt-1">
|
</ul>
|
||||||
{map(item.children, (subItem) => (
|
)}
|
||||||
<li key={subItem.id} className="mb-1">
|
</div>
|
||||||
<a
|
))}
|
||||||
onClick={() =>
|
|
||||||
setActive(subItem.id.toString())
|
|
||||||
}
|
|
||||||
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
|
|
||||||
subItem.id.toString() === active
|
|
||||||
? "font-semibold text-blue-600 dark:text-blue-400"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{subItem.displayName}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const SystemSettingsForm = (): ReactElement => {
|
export const SystemSettingsForm = (): ReactElement => {
|
||||||
const { mutate: flushDb, isLoading } = useMutation({
|
const { mutate: flushDb, isPending } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await axios({
|
await axios({
|
||||||
url: `http://localhost:3000/api/library/flushDb`,
|
url: `http://localhost:3000/api/library/flushDb`,
|
||||||
|
|||||||
@@ -8,10 +8,33 @@ import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
|||||||
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
|
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
|
||||||
const PotentialLibraryMatches = (props): ReactElement => {
|
interface PotentialLibraryMatchesProps {
|
||||||
|
matches: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComicBookMatch {
|
||||||
|
rawFileDetails: {
|
||||||
|
cover: {
|
||||||
|
filePath: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
containedIn: string;
|
||||||
|
extension: string;
|
||||||
|
fileSize: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local state type for redux selector
|
||||||
|
interface LocalRootState {
|
||||||
|
comicInfo?: {
|
||||||
|
comicBooksDetails?: ComicBookMatch[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PotentialLibraryMatches = (props: PotentialLibraryMatchesProps): ReactElement => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const comicBooks = useSelector(
|
const comicBooks = useSelector(
|
||||||
(state: RootState) => state.comicInfo.comicBooksDetails,
|
(state: LocalRootState) => state.comicInfo?.comicBooksDetails,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(getComicBooksDetailsByIds(props.matches));
|
dispatch(getComicBooksDetailsByIds(props.matches));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
|
|||||||
import React, { ReactElement, useState, useCallback } from "react";
|
import React, { ReactElement, useState, useCallback } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
||||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
||||||
import { Card } from "../shared/Carda";
|
import { Card } from "../shared/Carda";
|
||||||
import SlidingPane from "react-sliding-pane";
|
import SlidingPane from "react-sliding-pane";
|
||||||
@@ -14,38 +14,87 @@ import {
|
|||||||
} from "../../constants/endpoints";
|
} from "../../constants/endpoints";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const VolumeDetails = (props): ReactElement => {
|
interface VolumeDetailsProps {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComicObjectData {
|
||||||
|
sourcedMetadata: {
|
||||||
|
comicvine: {
|
||||||
|
id?: string;
|
||||||
|
volumeInformation: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
image: {
|
||||||
|
small_url: string;
|
||||||
|
};
|
||||||
|
publisher: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
issue_number: string;
|
||||||
|
description?: string;
|
||||||
|
matches?: unknown[];
|
||||||
|
image: {
|
||||||
|
small_url: string;
|
||||||
|
thumb_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryArc {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchItem {
|
||||||
|
_id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentForSlidingPanel {
|
||||||
|
[key: string]: {
|
||||||
|
content: () => React.ReactNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VolumeDetails = (_props: VolumeDetailsProps): ReactElement => {
|
||||||
// sliding panel config
|
// sliding panel config
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||||
const [matches, setMatches] = useState([]);
|
const [matches, setMatches] = useState<MatchItem[]>([]);
|
||||||
const [storyArcsData, setStoryArcsData] = useState([]);
|
const [storyArcsData, setStoryArcsData] = useState<StoryArc[]>([]);
|
||||||
const [active, setActive] = useState(1);
|
const [active, setActive] = useState(1);
|
||||||
|
|
||||||
// sliding panel init
|
// sliding panel init
|
||||||
const contentForSlidingPanel = {
|
const contentForSlidingPanel: ContentForSlidingPanel = {
|
||||||
potentialMatchesInLibrary: {
|
potentialMatchesInLibrary: {
|
||||||
content: () => {
|
content: () => {
|
||||||
const ids = map(matches, partialRight(pick, "_id"));
|
const ids = map(matches, partialRight(pick, "_id"));
|
||||||
const matchIds = ids.map((id: any) => id._id);
|
const matchIds = ids.map((id: MatchItem) => id._id).filter((id): id is string => !!id);
|
||||||
{
|
return <PotentialLibraryMatches matches={matchIds} />;
|
||||||
/* return <PotentialLibraryMatches matches={matchIds} />; */
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// sliding panel handlers
|
// sliding panel handlers
|
||||||
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches) => {
|
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches: MatchItem[]) => {
|
||||||
setSlidingPanelContentId("potentialMatchesInLibrary");
|
setSlidingPanelContentId("potentialMatchesInLibrary");
|
||||||
setMatches(potentialMatches);
|
setMatches(potentialMatches);
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// const analyzeIssues = useCallback((issues) => {
|
// Function to analyze issues (commented out but typed for future use)
|
||||||
// dispatch(analyzeLibrary(issues));
|
const analyzeIssues = useCallback((issues: IssueData[]) => {
|
||||||
// }, []);
|
// dispatch(analyzeLibrary(issues));
|
||||||
//
|
console.log("Analyzing issues:", issues);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
|
|
||||||
@@ -83,7 +132,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
// get story arcs
|
// get story arcs
|
||||||
const useGetStoryArcs = () => {
|
const useGetStoryArcs = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (comicObject) =>
|
mutationFn: async (comicObject: ComicObjectData) =>
|
||||||
axios({
|
axios({
|
||||||
url: `${COMICVINE_SERVICE_URI}/getResource`,
|
url: `${COMICVINE_SERVICE_URI}/getResource`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -93,7 +142,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
|
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data: { data: { results: StoryArc[] } }) => {
|
||||||
setStoryArcsData(data?.data.results);
|
setStoryArcsData(data?.data.results);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -111,13 +160,13 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
const IssuesInVolume = () => (
|
const IssuesInVolume = () => (
|
||||||
<>
|
<>
|
||||||
{!isUndefined(issuesForSeries) ? (
|
{!isUndefined(issuesForSeries) ? (
|
||||||
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
|
<div className="button" onClick={() => analyzeIssues(issuesForSeries?.data || [])}>
|
||||||
Analyze Library
|
Analyze Library
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<>
|
<>
|
||||||
{isSuccess &&
|
{isSuccess &&
|
||||||
issuesForSeries.data.map((issue) => {
|
issuesForSeries.data.map((issue: IssueData) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -157,7 +206,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
</article>
|
</article>
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
{isSuccess &&
|
{isSuccess &&
|
||||||
issuesForSeries?.data.map((issue) => {
|
issuesForSeries?.data.map((issue: IssueData) => {
|
||||||
return (
|
return (
|
||||||
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
|
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
|
||||||
<div className="flex flex-row gap-4 mb-2">
|
<div className="flex flex-row gap-4 mb-2">
|
||||||
@@ -170,11 +219,11 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
<div className="w-3/4">
|
<div className="w-3/4">
|
||||||
<p className="text-xl">{issue.name}</p>
|
<p className="text-xl">{issue.name}</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{convert(issue.description, {
|
{issue.description ? convert(issue.description, {
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p"],
|
selectors: ["p"],
|
||||||
},
|
},
|
||||||
})}
|
}) : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,9 +265,9 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
{!isEmpty(storyArcsData) && status === "success" && (
|
{!isEmpty(storyArcsData) && status === "success" && (
|
||||||
<>
|
<>
|
||||||
<ul>
|
<ul>
|
||||||
{storyArcsData.map((storyArc) => {
|
{storyArcsData.map((storyArc: StoryArc, idx: number) => {
|
||||||
return (
|
return (
|
||||||
<li>
|
<li key={idx}>
|
||||||
<span className="text-lg">{storyArc?.name}</span>
|
<span className="text-lg">{storyArc?.name}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -355,7 +404,7 @@ const VolumeDetails = (props): ReactElement => {
|
|||||||
width={"600px"}
|
width={"600px"}
|
||||||
>
|
>
|
||||||
{slidingPanelContentId !== "" &&
|
{slidingPanelContentId !== "" &&
|
||||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
(contentForSlidingPanel as ContentForSlidingPanel)[slidingPanelContentId]?.content()}
|
||||||
</SlidingPane>
|
</SlidingPane>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { ReactElement, useEffect, useMemo } from "react";
|
import React, { ReactElement, useMemo } from "react";
|
||||||
import { searchIssue } from "../../actions/fileops.actions";
|
|
||||||
import Card from "../shared/Carda";
|
import Card from "../shared/Carda";
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
@@ -8,8 +7,45 @@ import { Link } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import { CellContext, ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
||||||
export const Volumes = (props): ReactElement => {
|
interface VolumesProps {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeSourceData {
|
||||||
|
_id: string;
|
||||||
|
_source: {
|
||||||
|
sourcedMetadata: {
|
||||||
|
comicvine: {
|
||||||
|
volumeInformation: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
image: {
|
||||||
|
small_url: string;
|
||||||
|
};
|
||||||
|
publisher: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
count_of_issues: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
acquisition?: {
|
||||||
|
directconnect?: unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeInformation {
|
||||||
|
name: string;
|
||||||
|
publisher: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
count_of_issues?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Volumes = (_props: VolumesProps): ReactElement => {
|
||||||
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
|
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
|
||||||
const {
|
const {
|
||||||
data: volumes,
|
data: volumes,
|
||||||
@@ -34,17 +70,18 @@ export const Volumes = (props): ReactElement => {
|
|||||||
queryKey: ["volumes"],
|
queryKey: ["volumes"],
|
||||||
});
|
});
|
||||||
const columnData = useMemo(
|
const columnData = useMemo(
|
||||||
(): any => [
|
(): ColumnDef<VolumeSourceData, unknown>[] => [
|
||||||
{
|
{
|
||||||
header: "Volume Details",
|
header: "Volume Details",
|
||||||
id: "volumeDetails",
|
id: "volumeDetails",
|
||||||
minWidth: 450,
|
size: 450,
|
||||||
accessorFn: (row) => row,
|
accessorFn: (row: VolumeSourceData) => row,
|
||||||
cell: (row): any => {
|
cell: (info: CellContext<VolumeSourceData, VolumeSourceData>) => {
|
||||||
const comicObject = row.getValue();
|
const comicObject = info.getValue();
|
||||||
const {
|
const {
|
||||||
_source: { sourcedMetadata },
|
_source: { sourcedMetadata },
|
||||||
} = comicObject;
|
} = comicObject;
|
||||||
|
const description = sourcedMetadata.comicvine.volumeInformation.description || '';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-3 mt-5">
|
<div className="flex flex-row gap-3 mt-5">
|
||||||
<Link to={`/volume/details/${comicObject._id}`}>
|
<Link to={`/volume/details/${comicObject._id}`}>
|
||||||
@@ -61,9 +98,9 @@ export const Volumes = (props): ReactElement => {
|
|||||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
{ellipsize(
|
{description ? ellipsize(
|
||||||
convert(
|
convert(
|
||||||
sourcedMetadata.comicvine.volumeInformation.description,
|
description,
|
||||||
{
|
{
|
||||||
baseElements: {
|
baseElements: {
|
||||||
selectors: ["p"],
|
selectors: ["p"],
|
||||||
@@ -71,7 +108,7 @@ export const Volumes = (props): ReactElement => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
180,
|
180,
|
||||||
)}
|
) : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,9 +121,8 @@ export const Volumes = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Downloads",
|
header: "Downloads",
|
||||||
accessorKey: "_source.acquisition.directconnect",
|
accessorKey: "_source.acquisition.directconnect",
|
||||||
align: "right",
|
cell: (props: CellContext<VolumeSourceData, unknown[] | undefined>) => {
|
||||||
cell: (props) => {
|
const row = props.getValue() || [];
|
||||||
const row = props.getValue();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -105,16 +141,16 @@ export const Volumes = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Publisher",
|
header: "Publisher",
|
||||||
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
|
||||||
cell: (props): any => {
|
cell: (props: CellContext<VolumeSourceData, VolumeInformation>) => {
|
||||||
const row = props.getValue();
|
const row = props.getValue();
|
||||||
return <div className="mt-5 text-md">{row.publisher.name}</div>;
|
return <div className="mt-5 text-md">{row?.publisher?.name}</div>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Issue Count",
|
header: "Issue Count",
|
||||||
accessorKey:
|
accessorKey:
|
||||||
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
|
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
|
||||||
cell: (props): any => {
|
cell: (props: CellContext<VolumeSourceData, number>) => {
|
||||||
const row = props.getValue();
|
const row = props.getValue();
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
|
import React, { ReactElement } from "react";
|
||||||
import SearchBar from "../Library/SearchBar";
|
|
||||||
import T2Table from "../shared/T2Table";
|
import T2Table from "../shared/T2Table";
|
||||||
import MetadataPanel from "../shared/MetadataPanel";
|
import MetadataPanel from "../shared/MetadataPanel";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||||
|
import { CellContext } from "@tanstack/react-table";
|
||||||
|
|
||||||
export const WantedComics = (props): ReactElement => {
|
interface WantedComicsProps {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WantedSourceData {
|
||||||
|
_id: string;
|
||||||
|
_source: {
|
||||||
|
acquisition?: {
|
||||||
|
directconnect?: {
|
||||||
|
downloads: DownloadItem[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadItem {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcquisitionData {
|
||||||
|
directconnect?: {
|
||||||
|
downloads: DownloadItem[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WantedComics = (_props: WantedComicsProps): ReactElement => {
|
||||||
const {
|
const {
|
||||||
data: wantedComics,
|
data: wantedComics,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
@@ -39,9 +66,9 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
{
|
{
|
||||||
header: "Details",
|
header: "Details",
|
||||||
id: "comicDetails",
|
id: "comicDetails",
|
||||||
minWidth: 350,
|
size: 350,
|
||||||
accessorFn: (data) => data,
|
accessorFn: (data: WantedSourceData) => data,
|
||||||
cell: (value) => {
|
cell: (value: CellContext<WantedSourceData, WantedSourceData>) => {
|
||||||
const row = value.getValue()._source;
|
const row = value.getValue()._source;
|
||||||
return row && <MetadataPanel data={row} />;
|
return row && <MetadataPanel data={row} />;
|
||||||
},
|
},
|
||||||
@@ -53,17 +80,14 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "Files",
|
header: "Files",
|
||||||
align: "right",
|
|
||||||
accessorKey: "_source.acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (props) => {
|
cell: (props: CellContext<WantedSourceData, AcquisitionData | undefined>) => {
|
||||||
const {
|
const acquisition = props.getValue();
|
||||||
directconnect: { downloads },
|
const downloads = acquisition?.directconnect?.downloads || [];
|
||||||
} = props.getValue();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
// flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -78,17 +102,21 @@ export const WantedComics = (props): ReactElement => {
|
|||||||
header: "Download Details",
|
header: "Download Details",
|
||||||
id: "downloadDetails",
|
id: "downloadDetails",
|
||||||
accessorKey: "_source.acquisition",
|
accessorKey: "_source.acquisition",
|
||||||
cell: (data) => (
|
cell: (data: CellContext<WantedSourceData, AcquisitionData | undefined>) => {
|
||||||
<ol>
|
const acquisition = data.getValue();
|
||||||
{data.getValue().directconnect.downloads.map((download, idx) => {
|
const downloads = acquisition?.directconnect?.downloads || [];
|
||||||
return (
|
return (
|
||||||
<li className="is-size-7" key={idx}>
|
<ol>
|
||||||
{download.name}
|
{downloads.map((download: DownloadItem, idx: number) => {
|
||||||
</li>
|
return (
|
||||||
);
|
<li className="is-size-7" key={idx}>
|
||||||
})}
|
{download.name}
|
||||||
</ol>
|
</li>
|
||||||
),
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Type",
|
header: "Type",
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export const Canvas = ({ data }) => {
|
interface ColorHistogramData {
|
||||||
|
r: number[];
|
||||||
|
g: number[];
|
||||||
|
b: number[];
|
||||||
|
maxBrightness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanvasProps {
|
||||||
|
data: {
|
||||||
|
colorHistogramData: ColorHistogramData;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Canvas = ({ data }: CanvasProps) => {
|
||||||
const { colorHistogramData } = data;
|
const { colorHistogramData } = data;
|
||||||
const width = 559;
|
const width = 559;
|
||||||
const height = 200;
|
const height = 200;
|
||||||
const pixelRatio = window.devicePixelRatio;
|
const pixelRatio = window.devicePixelRatio;
|
||||||
|
|
||||||
const canvas = useRef(null);
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const context = canvas.current?.getContext("2d");
|
const context = canvas.current?.getContext("2d");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ interface ICardProps {
|
|||||||
orientation: string;
|
orientation: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
hasDetails?: boolean;
|
hasDetails?: boolean;
|
||||||
title?: PropTypes.ReactElementLike | null;
|
title?: React.ReactNode;
|
||||||
children?: PropTypes.ReactNodeLike;
|
children?: React.ReactNode;
|
||||||
borderColorClass?: string;
|
borderColorClass?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { Form, Field } from "react-final-form";
|
|||||||
import { hostNameValidator } from "../../../shared/utils/validator.utils";
|
import { hostNameValidator } from "../../../shared/utils/validator.utils";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
|
|
||||||
|
interface ConnectionFormProps {
|
||||||
|
initialData?: Record<string, unknown>;
|
||||||
|
submitHandler: (values: Record<string, unknown>) => void;
|
||||||
|
formHeading: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ConnectionForm = ({
|
export const ConnectionForm = ({
|
||||||
initialData,
|
initialData,
|
||||||
submitHandler,
|
submitHandler,
|
||||||
formHeading,
|
formHeading,
|
||||||
}): ReactElement => {
|
}: ConnectionFormProps): ReactElement => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState, Dispatch, SetStateAction } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import FocusTrap from "focus-trap-react";
|
import FocusTrap from "focus-trap-react";
|
||||||
import { ClassNames, DayPicker } from "react-day-picker";
|
import { DayPicker } from "react-day-picker";
|
||||||
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
|
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
|
||||||
import styles from "react-day-picker/dist/style.module.css";
|
import styles from "react-day-picker/dist/style.module.css";
|
||||||
|
|
||||||
export const DatePickerDialog = (props) => {
|
interface DatePickerDialogProps {
|
||||||
|
setter: Dispatch<SetStateAction<string>>;
|
||||||
|
apiAction?: () => void;
|
||||||
|
inputValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DatePickerDialog = (props: DatePickerDialogProps) => {
|
||||||
const { setter, apiAction } = props;
|
const { setter, apiAction } = props;
|
||||||
const [selected, setSelected] = useState<Date>();
|
const [selected, setSelected] = useState<Date>();
|
||||||
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
||||||
|
|
||||||
const classNames: ClassNames = {
|
// Use styles without casting - let TypeScript infer
|
||||||
...styles,
|
const classNames = styles as unknown as Record<string, string>;
|
||||||
head: "custom-head",
|
|
||||||
};
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const { x, y, reference, floating, strategy, refs, update } = useFloating({
|
const { refs, floatingStyles, strategy, update } = useFloating({
|
||||||
placement: "bottom-end",
|
placement: "bottom-end",
|
||||||
middleware: [offset(10), flip()],
|
middleware: [offset(10), flip()],
|
||||||
strategy: "absolute",
|
strategy: "absolute",
|
||||||
@@ -33,11 +37,11 @@ export const DatePickerDialog = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDaySelect = (date) => {
|
const handleDaySelect = (date: Date | undefined) => {
|
||||||
setSelected(date);
|
setSelected(date);
|
||||||
if (date) {
|
if (date) {
|
||||||
setter(format(date, "yyyy/MM/dd"));
|
setter(format(date, "yyyy/MM/dd"));
|
||||||
apiAction();
|
apiAction?.();
|
||||||
closePopper();
|
closePopper();
|
||||||
} else {
|
} else {
|
||||||
setter("");
|
setter("");
|
||||||
@@ -46,7 +50,7 @@ export const DatePickerDialog = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div ref={reference}>
|
<div ref={refs.setReference}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,10 +73,10 @@ export const DatePickerDialog = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={floating}
|
ref={refs.setFloating}
|
||||||
style={{
|
style={{
|
||||||
position: strategy,
|
...floatingStyles,
|
||||||
zIndex: "999",
|
zIndex: 999,
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
|
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef, CSSProperties } from "react";
|
||||||
|
|
||||||
export const Cover = forwardRef(
|
interface CoverProps {
|
||||||
|
url: string;
|
||||||
|
index: number;
|
||||||
|
faded?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Cover = forwardRef<HTMLDivElement, CoverProps & React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ url, index, faded, style, ...props }, ref) => {
|
({ url, index, faded, style, ...props }, ref) => {
|
||||||
const inlineStyles = {
|
const inlineStyles: CSSProperties = {
|
||||||
opacity: faded ? "0.2" : "1",
|
opacity: faded ? "0.2" : "1",
|
||||||
transformOrigin: "0 0",
|
transformOrigin: "0 0",
|
||||||
minHeight: index === 0 ? 300 : 300,
|
minHeight: index === 0 ? 300 : 300,
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
gridRowStart: index === 0 ? "span" : null,
|
gridRowStart: index === 0 ? "span" : undefined,
|
||||||
gridColumnStart: index === 0 ? "span" : null,
|
gridColumnStart: index === 0 ? "span" : undefined,
|
||||||
backgroundImage: `url("${url}")`,
|
backgroundImage: `url("${url}")`,
|
||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
DragOverlay,
|
DragOverlay,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
DragStartEvent,
|
||||||
|
DragEndEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
@@ -20,22 +22,27 @@ import { SortableCover } from "./SortableCover";
|
|||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
|
|
||||||
export const DnD = (data) => {
|
interface DnDProps {
|
||||||
const [items, setItems] = useState(data.data);
|
data: string[];
|
||||||
const [activeId, setActiveId] = useState(null);
|
onClickHandler: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DnD = ({ data, onClickHandler }: DnDProps) => {
|
||||||
|
const [items, setItems] = useState<string[]>(data);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
||||||
|
|
||||||
function handleDragStart(event) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
setActiveId(event.active.id);
|
setActiveId(event.active.id as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd(event) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
setItems((items) => {
|
setItems((items: string[]) => {
|
||||||
const oldIndex = items.indexOf(active.id);
|
const oldIndex = items.indexOf(active.id as string);
|
||||||
const newIndex = items.indexOf(over.id);
|
const newIndex = items.indexOf(over.id as string);
|
||||||
|
|
||||||
return arrayMove(items, oldIndex, newIndex);
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
});
|
});
|
||||||
@@ -56,13 +63,13 @@ export const DnD = (data) => {
|
|||||||
>
|
>
|
||||||
<SortableContext items={items} strategy={rectSortingStrategy}>
|
<SortableContext items={items} strategy={rectSortingStrategy}>
|
||||||
<Grid columns={4}>
|
<Grid columns={4}>
|
||||||
{map(items, (url, index) => {
|
{map(items, (url: string, index: number) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div key={url}>
|
||||||
<SortableCover key={url} url={url} index={index} />
|
<SortableCover url={url} index={index} />
|
||||||
<div
|
<div
|
||||||
className="mt-2 mb-2"
|
className="mt-2 mb-2"
|
||||||
onClick={(e) => data.onClickHandler(url)}
|
onClick={() => onClickHandler(url)}
|
||||||
>
|
>
|
||||||
<div className="box p-2 control-palette">
|
<div className="box p-2 control-palette">
|
||||||
<span className="tag is-warning mr-2">{index}</span>
|
<span className="tag is-warning mr-2">{index}</span>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import React from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
export function Grid({ children, columns }) {
|
interface GridProps {
|
||||||
|
children: ReactNode;
|
||||||
|
columns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Grid({ children, columns }: GridProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
export const SortableCover = (props) => {
|
interface SortableCoverProps {
|
||||||
|
url: string;
|
||||||
|
index: number;
|
||||||
|
faded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortableCover = (props: SortableCoverProps) => {
|
||||||
const sortable = useSortable({ id: props.url });
|
const sortable = useSortable({ id: props.url });
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
isDragging,
|
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import React, { useState } from "react";
|
import React, { ReactNode, useState } from "react";
|
||||||
import { useFloating, offset, flip } from "@floating-ui/react-dom";
|
import { useFloating, offset, flip } from "@floating-ui/react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
|
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
|
||||||
|
|
||||||
const PopoverButton = ({ content, clickHandler }) => {
|
interface PopoverButtonProps {
|
||||||
|
content: ReactNode;
|
||||||
|
clickHandler: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverButton = ({ content, clickHandler }: PopoverButtonProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
// Use destructuring to obtain the reference and floating setters, among other values.
|
// Use destructuring to obtain the reference and floating setters, among other values.
|
||||||
const { x, y, refs, strategy, floatingStyles } = useFloating({
|
const { x, y, refs, strategy, floatingStyles } = useFloating({
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const router = createBrowserRouter([
|
|||||||
path: "comic/details/:comicObjectId",
|
path: "comic/details/:comicObjectId",
|
||||||
element: <ComicDetailContainer />,
|
element: <ComicDetailContainer />,
|
||||||
},
|
},
|
||||||
{ path: "import", element: <Import path={"./comics"} /> },
|
{ path: "import", element: <Import /> },
|
||||||
{ path: "search", element: <Search /> },
|
{ path: "search", element: <Search /> },
|
||||||
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
|
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
|
||||||
{ path: "volumes", element: <Volumes /> },
|
{ path: "volumes", element: <Volumes /> },
|
||||||
|
|||||||
@@ -21,41 +21,43 @@ import { Socket } from "airdcpp-apisocket";
|
|||||||
* });
|
* });
|
||||||
* await socket.connect();
|
* await socket.connect();
|
||||||
*/
|
*/
|
||||||
class AirDCPPSocket {
|
interface AirDCPPConfiguration {
|
||||||
/**
|
protocol: string;
|
||||||
* Creates a new AirDC++ socket connection instance.
|
hostname: string;
|
||||||
*
|
username: string;
|
||||||
* @constructor
|
password: string;
|
||||||
* @param {Object} configuration - Connection configuration object
|
|
||||||
* @param {string} configuration.protocol - HTTP protocol ("http" or "https")
|
|
||||||
* @param {string} configuration.hostname - Server hostname with optional port
|
|
||||||
* @param {string} configuration.username - AirDC++ username for authentication
|
|
||||||
* @param {string} configuration.password - AirDC++ password for authentication
|
|
||||||
* @returns {Socket} Configured AirDC++ socket instance
|
|
||||||
*/
|
|
||||||
constructor(configuration) {
|
|
||||||
let socketProtocol = "";
|
|
||||||
if (configuration.protocol === "https") {
|
|
||||||
socketProtocol = "wss";
|
|
||||||
} else {
|
|
||||||
socketProtocol = "ws";
|
|
||||||
}
|
|
||||||
const options = {
|
|
||||||
url: `${socketProtocol}://${configuration.hostname}/api/v1/`,
|
|
||||||
autoReconnect: true,
|
|
||||||
reconnectInterval: 5,
|
|
||||||
logLevel: "verbose",
|
|
||||||
ignoredListenerEvents: [
|
|
||||||
"transfer_statistics",
|
|
||||||
"hash_statistics",
|
|
||||||
"hub_counts_updated",
|
|
||||||
],
|
|
||||||
username: `${configuration.username}`,
|
|
||||||
password: `${configuration.password}`,
|
|
||||||
};
|
|
||||||
const AirDCPPSocketInstance = Socket(options, window.WebSocket as any);
|
|
||||||
return AirDCPPSocketInstance;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AirDCPPSocket;
|
/**
|
||||||
|
* Creates a new AirDC++ socket connection instance.
|
||||||
|
*
|
||||||
|
* @param {AirDCPPConfiguration} configuration - Connection configuration object
|
||||||
|
* @returns {ReturnType<typeof Socket>} Configured AirDC++ socket instance
|
||||||
|
*/
|
||||||
|
function createAirDCPPSocket(configuration: AirDCPPConfiguration): ReturnType<typeof Socket> {
|
||||||
|
let socketProtocol = "";
|
||||||
|
if (configuration.protocol === "https") {
|
||||||
|
socketProtocol = "wss";
|
||||||
|
} else {
|
||||||
|
socketProtocol = "ws";
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
url: `${socketProtocol}://${configuration.hostname}/api/v1/`,
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectInterval: 5,
|
||||||
|
logLevel: "verbose" as const,
|
||||||
|
ignoredListenerEvents: [
|
||||||
|
"transfer_statistics",
|
||||||
|
"hash_statistics",
|
||||||
|
"hub_counts_updated",
|
||||||
|
],
|
||||||
|
username: `${configuration.username}`,
|
||||||
|
password: `${configuration.password}`,
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const AirDCPPSocketInstance = Socket(options, window.WebSocket as any);
|
||||||
|
return AirDCPPSocketInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createAirDCPPSocket;
|
||||||
|
export type { AirDCPPConfiguration };
|
||||||
|
|||||||
@@ -5,14 +5,12 @@
|
|||||||
* @module services/api/SearchApi
|
* @module services/api/SearchApi
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import SocketService from "../DcppSearchService";
|
import createAirDCPPSocket, { AirDCPPConfiguration } from "../DcppSearchService";
|
||||||
import {
|
import {
|
||||||
SearchQuery,
|
SearchQuery,
|
||||||
SearchInstance,
|
SearchInstance,
|
||||||
PriorityEnum,
|
PriorityEnum,
|
||||||
SearchResponse,
|
|
||||||
} from "threetwo-ui-typings";
|
} from "threetwo-ui-typings";
|
||||||
import SearchConstants from "../../constants/search.constants";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration data for initiating an AirDC++ search.
|
* Configuration data for initiating an AirDC++ search.
|
||||||
@@ -28,6 +26,31 @@ interface SearchData {
|
|||||||
priority: PriorityEnum;
|
priority: PriorityEnum;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchInfo {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket instance holder
|
||||||
|
let socketInstance: ReturnType<typeof createAirDCPPSocket> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the socket service with configuration
|
||||||
|
*/
|
||||||
|
const initializeSocket = (config: AirDCPPConfiguration) => {
|
||||||
|
socketInstance = createAirDCPPSocket(config);
|
||||||
|
return socketInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current socket instance
|
||||||
|
*/
|
||||||
|
const getSocket = () => {
|
||||||
|
if (!socketInstance) {
|
||||||
|
throw new Error("Socket not initialized. Call initializeSocket first.");
|
||||||
|
}
|
||||||
|
return socketInstance;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pauses execution for a specified duration.
|
* Pauses execution for a specified duration.
|
||||||
*
|
*
|
||||||
@@ -55,18 +78,19 @@ function sleep(ms: number): Promise<NodeJS.Timeout> {
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
export const search = async (data: SearchData) => {
|
export const search = async (data: SearchData) => {
|
||||||
await SocketService.connect();
|
const socket = getSocket();
|
||||||
const instance: SearchInstance = await SocketService.post("search");
|
await socket.connect();
|
||||||
const unsubscribe = await SocketService.addListener(
|
const instance: SearchInstance = await socket.post("search");
|
||||||
|
const unsubscribe = await socket.addListener(
|
||||||
"search",
|
"search",
|
||||||
"search_hub_searches_sent",
|
"search_hub_searches_sent",
|
||||||
(searchInfo) => {
|
(searchInfo: SearchInfo) => {
|
||||||
onSearchSent(data, instance, unsubscribe, searchInfo);
|
onSearchSent(data, instance, unsubscribe, searchInfo);
|
||||||
},
|
},
|
||||||
instance.id,
|
instance.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchQueueInfo = await SocketService.post(
|
const searchQueueInfo = await socket.post(
|
||||||
`search/${instance.id}/hub_search`,
|
`search/${instance.id}/hub_search`,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
@@ -84,19 +108,25 @@ export const search = async (data: SearchData) => {
|
|||||||
* @param {Function} unsubscribe - Cleanup function to remove the event listener
|
* @param {Function} unsubscribe - Cleanup function to remove the event listener
|
||||||
* @param {Object} searchInfo - Information about the sent search
|
* @param {Object} searchInfo - Information about the sent search
|
||||||
*/
|
*/
|
||||||
const onSearchSent = async (item, instance, unsubscribe, searchInfo) => {
|
const onSearchSent = async (
|
||||||
|
item: SearchData,
|
||||||
|
instance: SearchInstance,
|
||||||
|
unsubscribe: () => void,
|
||||||
|
searchInfo: SearchInfo
|
||||||
|
) => {
|
||||||
// Collect the results for 5 seconds
|
// Collect the results for 5 seconds
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
|
|
||||||
|
const socket = getSocket();
|
||||||
// Get only the first result (results are sorted by relevance)
|
// Get only the first result (results are sorted by relevance)
|
||||||
const results = await SocketService.get(
|
const results = await socket.get(
|
||||||
`search/${instance.id}/results/0/100`,
|
`search/${instance.id}/results/0/100`,
|
||||||
);
|
) as unknown[] | undefined;
|
||||||
|
|
||||||
if (results.length > 0) {
|
if (results && Array.isArray(results) && results.length > 0) {
|
||||||
// We have results, download the best one
|
// We have results, download the best one
|
||||||
// const result = results[0];
|
// const result = results[0];
|
||||||
// SocketService.post(`search/${instance.id}/results/${result.id}/download`, {
|
// socket.post(`search/${instance.id}/results/${result.id}/download`, {
|
||||||
// priority: Utils.toApiPriority(item.priority),
|
// priority: Utils.toApiPriority(item.priority),
|
||||||
// target_directory: item.target_directory,
|
// target_directory: item.target_directory,
|
||||||
// });
|
// });
|
||||||
@@ -104,3 +134,5 @@ const onSearchSent = async (item, instance, unsubscribe, searchInfo) => {
|
|||||||
// Remove listener for this search instance
|
// Remove listener for this search instance
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { initializeSocket };
|
||||||
|
|||||||
@@ -53,7 +53,42 @@ import { escapePoundSymbol } from "./formatting.utils";
|
|||||||
* });
|
* });
|
||||||
* // Returns rawFileDetails cover (priority 1) if available
|
* // Returns rawFileDetails cover (priority 1) if available
|
||||||
*/
|
*/
|
||||||
export const determineCoverFile = (data): any => {
|
interface CoverFileData {
|
||||||
|
rawFileDetails?: {
|
||||||
|
cover?: {
|
||||||
|
filePath?: string | null;
|
||||||
|
} | null;
|
||||||
|
name?: string | null;
|
||||||
|
} | null;
|
||||||
|
comicvine?: {
|
||||||
|
image?: {
|
||||||
|
small_url?: string | null;
|
||||||
|
} | null;
|
||||||
|
name?: string | null;
|
||||||
|
publisher?: {
|
||||||
|
name?: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
locg?: {
|
||||||
|
cover?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
publisher?: string | null;
|
||||||
|
} | null;
|
||||||
|
wanted?: unknown;
|
||||||
|
comicInfo?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CoverFileData };
|
||||||
|
|
||||||
|
interface CoverFileEntry {
|
||||||
|
objectReference: string;
|
||||||
|
priority: number;
|
||||||
|
url: string;
|
||||||
|
issueName: string;
|
||||||
|
publisher: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const determineCoverFile = (data: CoverFileData): CoverFileEntry => {
|
||||||
const coverFile = {
|
const coverFile = {
|
||||||
rawFile: {
|
rawFile: {
|
||||||
objectReference: "rawFileDetails",
|
objectReference: "rawFileDetails",
|
||||||
@@ -87,27 +122,27 @@ export const determineCoverFile = (data): any => {
|
|||||||
|
|
||||||
// Extract ComicVine metadata
|
// Extract ComicVine metadata
|
||||||
if (!isEmpty(data.comicvine)) {
|
if (!isEmpty(data.comicvine)) {
|
||||||
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
|
coverFile.comicvine.url = data?.comicvine?.image?.small_url || "";
|
||||||
coverFile.comicvine.issueName = data.comicvine?.name;
|
coverFile.comicvine.issueName = data.comicvine?.name || "";
|
||||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
coverFile.comicvine.publisher = data.comicvine?.publisher?.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract raw file details
|
// Extract raw file details
|
||||||
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
|
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails?.cover?.filePath) {
|
||||||
const encodedFilePath = encodeURI(
|
const encodedFilePath = encodeURI(
|
||||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||||
);
|
);
|
||||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
coverFile.rawFile.issueName = data.rawFileDetails.name || "";
|
||||||
} else if (!isEmpty(data.rawFileDetails)) {
|
} else if (!isEmpty(data.rawFileDetails)) {
|
||||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
coverFile.rawFile.issueName = data.rawFileDetails?.name || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract League of Comic Geeks metadata
|
// Extract League of Comic Geeks metadata
|
||||||
if (!isNil(data.locg)) {
|
if (!isNil(data.locg)) {
|
||||||
coverFile.locg.url = data.locg.cover;
|
coverFile.locg.url = data.locg.cover || "";
|
||||||
coverFile.locg.issueName = data.locg.name;
|
coverFile.locg.issueName = data.locg.name || "";
|
||||||
coverFile.locg.publisher = data.locg.publisher;
|
coverFile.locg.publisher = data.locg.publisher || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = filter(coverFile, (item) => item.url !== "");
|
const result = filter(coverFile, (item) => item.url !== "");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { transform, isEqual, isObject } from "lodash";
|
|||||||
* const obj2 = { a: 1, b: { c: 2, d: 4 } };
|
* const obj2 = { a: 1, b: { c: 2, d: 4 } };
|
||||||
* difference(obj1, obj2); // returns { b: { d: 3 } }
|
* difference(obj1, obj2); // returns { b: { d: 3 } }
|
||||||
*/
|
*/
|
||||||
export const difference = (object, base) => {
|
export const difference = (object: Record<string, unknown>, base: Record<string, unknown>): Record<string, unknown> => {
|
||||||
return changes(object, base);
|
return changes(object, base);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ export const difference = (object, base) => {
|
|||||||
* @param {Object} base - The base object to compare against
|
* @param {Object} base - The base object to compare against
|
||||||
* @returns {Object} Object containing the differences
|
* @returns {Object} Object containing the differences
|
||||||
*/
|
*/
|
||||||
const changes = (object, base) => {
|
const changes = (object: Record<string, unknown>, base: Record<string, unknown>): Record<string, unknown> => {
|
||||||
return transform(object, (result, value, key) => {
|
return transform(object, (result: Record<string, unknown>, value: unknown, key: string) => {
|
||||||
if (!isEqual(value, base[key])) {
|
if (!isEqual(value, base[key])) {
|
||||||
result[key] =
|
(result as Record<string, unknown>)[key] =
|
||||||
isObject(value) && isObject(base[key])
|
isObject(value) && isObject(base[key])
|
||||||
? changes(value, base[key])
|
? changes(value as Record<string, unknown>, base[key] as Record<string, unknown>)
|
||||||
: value;
|
: value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -81,7 +81,7 @@ export type TraverseFunction<T> = (
|
|||||||
* // ".b = [object Object]"
|
* // ".b = [object Object]"
|
||||||
* // "b.c = 2"
|
* // "b.c = 2"
|
||||||
*/
|
*/
|
||||||
export const traverseObject = <T = Record<string, unknown>>(
|
export const traverseObject = <T extends Record<string, unknown> = Record<string, unknown>>(
|
||||||
object: T,
|
object: T,
|
||||||
fn: TraverseFunction<T>,
|
fn: TraverseFunction<T>,
|
||||||
): void => traverseInternal(object, fn, []);
|
): void => traverseInternal(object, fn, []);
|
||||||
@@ -96,15 +96,15 @@ export const traverseObject = <T = Record<string, unknown>>(
|
|||||||
* @param {string[]} [scope=[]] - Current path scope in the object hierarchy
|
* @param {string[]} [scope=[]] - Current path scope in the object hierarchy
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const traverseInternal = <T = Record<string, unknown>>(
|
const traverseInternal = <T extends Record<string, unknown>>(
|
||||||
object: T,
|
object: T,
|
||||||
fn: TraverseFunction<T>,
|
fn: TraverseFunction<T>,
|
||||||
scope: string[] = [],
|
scope: string[] = [],
|
||||||
): void => {
|
): void => {
|
||||||
Object.entries(object).forEach(([key, value]) => {
|
Object.entries(object as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
fn.apply(this, [object, key, value, scope]);
|
fn.apply(undefined, [object, key, value, scope]);
|
||||||
if (value !== null && typeof value === "object") {
|
if (value !== null && typeof value === "object") {
|
||||||
traverseInternal(value, fn, scope.concat(key));
|
traverseInternal(value as T, fn, scope.concat(key));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,20 +27,22 @@ export type LibraryStats = {
|
|||||||
totalDocuments: number;
|
totalDocuments: number;
|
||||||
comicDirectorySize: {
|
comicDirectorySize: {
|
||||||
totalSizeInGB?: number | null;
|
totalSizeInGB?: number | null;
|
||||||
|
fileCount?: number;
|
||||||
};
|
};
|
||||||
comicsMissingFiles: number;
|
comicsMissingFiles: number;
|
||||||
statistics?: Array<{
|
statistics?: Array<{
|
||||||
issues?: unknown[];
|
issues?: unknown[] | null;
|
||||||
issuesWithComicInfoXML?: unknown[];
|
issuesWithComicInfoXML?: unknown[] | null;
|
||||||
|
fileLessComics?: unknown[] | null;
|
||||||
fileTypes?: Array<{
|
fileTypes?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
data: unknown[];
|
data: unknown[];
|
||||||
}>;
|
}> | null;
|
||||||
publisherWithMostComicsInLibrary?: Array<{
|
publisherWithMostComicsInLibrary?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
count: number;
|
count: number;
|
||||||
}>;
|
}> | null;
|
||||||
}>;
|
}> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
47
src/client/types/index.d.ts
vendored
47
src/client/types/index.d.ts
vendored
@@ -47,6 +47,53 @@ declare module "*.scss";
|
|||||||
*/
|
*/
|
||||||
declare module "*.css";
|
declare module "*.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module declaration for html-to-text library.
|
||||||
|
* Provides the convert function for converting HTML to plain text.
|
||||||
|
*/
|
||||||
|
declare module "html-to-text" {
|
||||||
|
export function convert(html: string, options?: {
|
||||||
|
baseElements?: {
|
||||||
|
selectors?: string[];
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module declaration for react-table library.
|
||||||
|
*/
|
||||||
|
declare module "react-table" {
|
||||||
|
export function useTable(options: any, ...plugins: any[]): any;
|
||||||
|
export function usePagination(hooks: any): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module declaration for react-masonry-css library.
|
||||||
|
*/
|
||||||
|
declare module "react-masonry-css" {
|
||||||
|
import { ComponentType } from "react";
|
||||||
|
const Masonry: ComponentType<any>;
|
||||||
|
export default Masonry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RootState type for Redux/Zustand stores
|
||||||
|
* Used across components for state typing
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
type RootState = {
|
||||||
|
fileOps: {
|
||||||
|
recentComics: { docs: any[]; totalDocs: number };
|
||||||
|
libraryServiceStatus: any;
|
||||||
|
};
|
||||||
|
comicInfo: {
|
||||||
|
comicBooksDetails: any;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @note Comic types are now generated from GraphQL schema.
|
* @note Comic types are now generated from GraphQL schema.
|
||||||
* Import from '../../graphql/generated' instead of defining types here.
|
* Import from '../../graphql/generated' instead of defining types here.
|
||||||
|
|||||||
17
src/client/types/react-masonry-css.d.ts
vendored
Normal file
17
src/client/types/react-masonry-css.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Type declarations for react-masonry-css
|
||||||
|
* @module react-masonry-css
|
||||||
|
*/
|
||||||
|
declare module "react-masonry-css" {
|
||||||
|
import { ComponentType, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface MasonryProps {
|
||||||
|
breakpointCols?: number | { default: number; [key: number]: number };
|
||||||
|
className?: string;
|
||||||
|
columnClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Masonry: ComponentType<MasonryProps>;
|
||||||
|
export default Masonry;
|
||||||
|
}
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "nodenext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"types": ["node", "vite/client"]
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"eslint.workingDirectories": [
|
"eslint.workingDirectories": [
|
||||||
@@ -25,5 +25,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["./src/client/*", "./src/client/types/**/*.d.ts"]
|
"include": ["./src/client/**/*", "./src/client/types/**/*.d.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"./src/client/**/*.test.tsx",
|
||||||
|
"./src/client/**/*.test.ts",
|
||||||
|
"./src/client/**/*.stories.tsx",
|
||||||
|
"./src/client/graphql/generated.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user