Massive ts error cleanup

This commit is contained in:
Rishi Ghan
2026-04-15 13:30:28 -04:00
parent 0c363dd8ae
commit 3ea9b83ed9
59 changed files with 21787 additions and 1581 deletions

20050
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
"@graphql-codegen/cli": "^6.1.2",
"@graphql-codegen/typescript": "^5.0.8",
"@graphql-codegen/typescript-operations": "^5.0.8",
@@ -110,15 +111,17 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/ellipsize": "^0.1.3",
"@types/html-to-text": "^9.0.4",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.6.0",
"@types/prop-types": "^15.7.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"@types/react-table": "^7.7.20",
"autoprefixer": "^10.4.27",
"docdash": "^2.0.2",
"@eslint/js": "^10.0.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",

View File

@@ -32,9 +32,38 @@ import {
AIRDCPP_SOCKET_DISCONNECTED,
} from "../constants/action-types";
import { isNil } from "lodash";
import axios from "axios";
import axios, { AxiosResponse } from "axios";
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.
@@ -58,7 +87,7 @@ export const sleep = (ms: number): Promise<NodeJS.Timeout> => {
* @returns {Function} Redux thunk function
*/
export const toggleAirDCPPSocketConnectionStatus =
(status: String, payload?: any) => async (dispatch) => {
(status: String, payload?: unknown) => async (dispatch: ThunkDispatch) => {
switch (status) {
case "connected":
dispatch({
@@ -102,16 +131,16 @@ export const downloadAirDCPPItem =
comicObjectId: String,
name: String,
size: Number,
type: any,
ADCPPSocket: any,
credentials: any,
): void =>
async (dispatch) => {
type: unknown,
ADCPPSocket: { isConnected: () => boolean; connect: () => Promise<void>; post: (url: string) => Promise<{ bundle_info: { id: string } }> },
credentials: unknown,
) =>
async (dispatch: ThunkDispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
}
let bundleDBImportResult = {};
let bundleDBImportResult: AxiosResponse | null = null;
const downloadResult = await ADCPPSocket.post(
`search/${searchInstanceId}/results/${resultId}/download`,
);
@@ -140,7 +169,7 @@ export const downloadAirDCPPItem =
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
comicBookDetail: bundleDBImportResult.data,
comicBookDetail: bundleDBImportResult?.data,
IMS_inProgress: false,
});
}
@@ -161,8 +190,8 @@ export const downloadAirDCPPItem =
* @throws {Error} If fetching comic or bundles fails
*/
export const getBundlesForComic =
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
async (dispatch) => {
(comicObjectId: string, ADCPPSocket: { isConnected: () => boolean; connect: () => Promise<void>; get: (url: string) => Promise<AirDCPPBundle> }, credentials: unknown) =>
async (dispatch: ThunkDispatch) => {
try {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
@@ -181,7 +210,7 @@ export const getBundlesForComic =
if (comicObject.data.acquisition.directconnect) {
const filteredBundles =
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
async ({ bundleId }: AirDCPPDownloadItem) => {
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
},
);
@@ -207,7 +236,7 @@ export const getBundlesForComic =
* @throws {Error} If fetching transfers fails
*/
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 {
if (!ADCPPSocket.isConnected()) {
await ADCPPSocket.connect();
@@ -218,7 +247,7 @@ export const getTransfers =
type: AIRDCPP_TRANSFERS_FETCHED,
bundles,
});
const bundleIds = bundles.map((bundle) => bundle.id);
const bundleIds = bundles.map((bundle: AirDCPPBundle) => bundle.id);
// get issues with matching bundleIds
const issue_bundles = await axios({
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,

View File

@@ -28,6 +28,52 @@ import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} 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.
@@ -53,7 +99,7 @@ const cachedAxios = setupCache(axios);
* @param {Object} options - Query parameters for the pull list request
* @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 {
dispatch({
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
@@ -84,7 +130,7 @@ export const getWeeklyPullList = (options) => async (dispatch) => {
* @param {any} [options.data] - Request body data
* @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 {
dispatch({
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
*/
export const getIssuesForSeries =
(comicObjectID: string) => async (dispatch) => {
(comicObjectID: string) => async (dispatch: ThunkDispatch) => {
dispatch({
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
});
@@ -161,11 +207,11 @@ export const getIssuesForSeries =
* @param {string} issues[].volume.name - Volume name
* @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({
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
});
const queryObjects = issues.map((issue) => {
const queryObjects = issues.map((issue: ComicVineIssue) => {
const { id, name, issue_number } = issue;
return {
issueId: id,
@@ -194,7 +240,7 @@ export const analyzeLibrary = (issues) => async (dispatch) => {
*
* @returns {Function} Redux thunk function that dispatches LIBRARY_STATISTICS_FETCHED
*/
export const getLibraryStatistics = () => async (dispatch) => {
export const getLibraryStatistics = () => async (dispatch: ThunkDispatch) => {
dispatch({
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
*/
export const getComicBookDetailById =
(comicBookObjectId: string) => async (dispatch) => {
(comicBookObjectId: string) => async (dispatch: ThunkDispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,
@@ -244,7 +290,7 @@ export const getComicBookDetailById =
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED
*/
export const getComicBooksDetailsByIds =
(comicBookObjectIds: Array<string>) => async (dispatch) => {
(comicBookObjectIds: Array<string>) => async (dispatch: ThunkDispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,
@@ -272,7 +318,7 @@ export const getComicBooksDetailsByIds =
* @returns {Function} Redux thunk function that dispatches IMS_COMIC_BOOK_DB_OBJECT_FETCHED
*/
export const applyComicVineMatch =
(match, comicObjectId) => async (dispatch) => {
(match: ComicVineMatch, comicObjectId: string) => async (dispatch: ThunkDispatch) => {
dispatch({
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
IMS_inProgress: true,

View File

@@ -43,6 +43,55 @@ import {
} from "../constants/action-types";
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.
@@ -51,7 +100,7 @@ import { isNil } from "lodash";
* @param {string} [serviceName] - Optional specific service name to check
* @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
.request({
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
* @return the comic book metadata
*/
export const fetchComicBookMetadata = () => async (dispatch) => {
export const fetchComicBookMetadata = () => async (dispatch: ThunkDispatch) => {
dispatch({
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({
url: `${JOB_QUEUE_SERVICE_BASE_URI}/getJobResultStatistics`,
method: "GET",
@@ -125,7 +174,7 @@ export const getImportJobResultStatistics = () => async (dispatch) => {
};
export const setQueueControl =
(queueAction: string, queueStatus: string) => async (dispatch) => {
(queueAction: string, queueStatus: string) => async (dispatch: ThunkDispatch) => {
dispatch({
type: LS_SET_QUEUE_STATUS,
meta: { remote: true },
@@ -138,7 +187,7 @@ export const setQueueControl =
* @return metadata for the comic book object categories
* @param options
**/
export const getComicBooks = (options) => async (dispatch) => {
export const getComicBooks = (options: GetComicBooksOptions) => async (dispatch: ThunkDispatch) => {
const { paginationOptions, predicate, comicStatus } = options;
const response = await axios.request({
@@ -174,7 +223,7 @@ export const getComicBooks = (options) => async (dispatch) => {
* @param payload
*/
export const importToDB =
(sourceName: string, metadata?: any) => (dispatch) => {
(sourceName: string, metadata?: unknown) => (dispatch: ThunkDispatch) => {
try {
const comicBookMetadata = {
importType: "new",
@@ -218,7 +267,7 @@ export const importToDB =
}
};
export const fetchVolumeGroups = () => async (dispatch) => {
export const fetchVolumeGroups = () => async (dispatch: ThunkDispatch) => {
try {
dispatch({
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
@@ -236,7 +285,7 @@ export const fetchVolumeGroups = () => async (dispatch) => {
}
};
export const fetchComicVineMatches =
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
(searchPayload: CVSearchPayload, issueSearchQuery: IssueSearchQuery, seriesSearchQuery?: unknown) => async (dispatch: ThunkDispatch) => {
try {
dispatch({
type: CV_API_CALL_IN_PROGRESS,
@@ -266,14 +315,14 @@ export const fetchComicVineMatches =
},
})
.then((response) => {
let matches: any = [];
let matches: CVMatch[] = [];
if (
!isNil(response.data.results) &&
response.data.results.length === 1
) {
matches = response.data.results;
} else {
matches = response.data.map((match) => match);
matches = response.data.map((match: CVMatch) => match);
}
dispatch({
type: CV_SEARCH_SUCCESS,
@@ -300,8 +349,8 @@ export const fetchComicVineMatches =
* @returns {any}
*/
export const extractComicArchive =
(path: string, options: any): any =>
async (dispatch) => {
(path: string, options: Record<string, unknown>) =>
async (dispatch: ThunkDispatch) => {
dispatch({
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
});
@@ -324,7 +373,7 @@ export const extractComicArchive =
* @param {any} options
* @returns {any}
*/
export const searchIssue = (query, options) => async (dispatch) => {
export const searchIssue = (query: Record<string, unknown>, options: SearchQueryOptions) => async (dispatch: ThunkDispatch) => {
dispatch({
type: SS_SEARCH_IN_PROGRESS,
});
@@ -374,7 +423,7 @@ export const searchIssue = (query, options) => async (dispatch) => {
}
};
export const analyzeImage =
(imageFilePath: string | Buffer) => async (dispatch) => {
(imageFilePath: string | Buffer) => async (dispatch: ThunkDispatch) => {
dispatch({
type: FILEOPS_STATE_RESET,
});

View File

@@ -8,6 +8,32 @@ import axios from "axios";
import { isNil } from "lodash";
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
* @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 } }
*/
export const fetchMetronResource = async (options) => {
export const fetchMetronResource = async (options: MetronFetchOptions): Promise<MetronResourceResult> => {
const metronResourceResults = await axios.post(
`${METRON_SERVICE_URI}/fetchResource`,
options,
);
const results = metronResourceResults.data.results.map((result) => {
const results = metronResourceResults.data.results.map((result: MetronResultItem) => {
return {
label: result.name || result.__str__,
value: result.id,

View File

@@ -11,12 +11,37 @@ import {
SETTINGS_CALL_IN_PROGRESS,
SETTINGS_DB_FLUSH_SUCCESS,
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
} from "../reducers/settings.reducer";
} from "../constants/action-types";
import {
LIBRARY_SERVICE_BASE_URI,
SETTINGS_SERVICE_BASE_URI,
QBITTORRENT_SERVICE_BASE_URI,
} 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.
@@ -25,7 +50,7 @@ import {
* @param {string} [settingsKey] - Optional specific settings key to fetch
* @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({
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
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
*/
export const deleteSettings = () => async (dispatch) => {
export const deleteSettings = () => async (dispatch: ThunkDispatch) => {
const result = await axios({
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
method: "POST",
@@ -65,7 +90,7 @@ export const deleteSettings = () => async (dispatch) => {
*
* @returns {Function} Redux thunk function that dispatches SETTINGS_DB_FLUSH_SUCCESS
*/
export const flushDb = () => async (dispatch) => {
export const flushDb = () => async (dispatch: ThunkDispatch) => {
dispatch({
type: SETTINGS_CALL_IN_PROGRESS,
});
@@ -94,7 +119,7 @@ export const flushDb = () => async (dispatch) => {
* @param {string} [hostInfo.password] - Authentication password
* @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({
url: `${QBITTORRENT_SERVICE_BASE_URI}/connect`,
method: "POST",
@@ -122,4 +147,4 @@ export const getQBitTorrentClientInfo = (hostInfo) => async (dispatch) => {
* @returns {Function} Redux thunk function (currently not implemented)
* @todo Implement Prowlarr connection verification
*/
export const getProwlarrConnectionInfo = (hostInfo) => async (dispatch) => {};
export const getProwlarrConnectionInfo = (hostInfo: ProwlarrHostInfo) => async (_dispatch: ThunkDispatch) => {};

View File

@@ -1,35 +1,45 @@
import React, {
useCallback,
ReactElement,
useEffect,
useRef,
useState,
} from "react";
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
import { RootState, SearchInstance } from "threetwo-ui-typings";
import ellipsize from "ellipsize";
import { Form, Field } from "react-final-form";
import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
import type { Socket } from "socket.io-client";
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 = (
props: AcquisitionPanelProps,
): ReactElement => {
const socketRef = useRef<Socket>();
const queryClient = useQueryClient();
const socketRef = useRef<Socket | undefined>(undefined);
const [dcppQuery, setDcppQuery] = useState({});
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<any[]>([]);
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<AirDCPPSearchResult[]>([]);
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<any>({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<any>({});
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<{ id?: string; owner?: string; expires_in?: number }>({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<{ query?: { pattern: string; extensions: string[]; file_type: string } }>({});
const { comicObjectId } = props;
const issueName = props.query.issue.name || "";
@@ -134,13 +144,13 @@ export const AcquisitionPanel = (
};
const download = async (
searchInstanceId: Number,
resultId: String,
comicObjectId: String,
name: String,
size: Number,
type: any,
config: any,
searchInstanceId: string | number,
resultId: string,
comicObjectId: string,
name: string,
size: number,
type: unknown,
config: Record<string, unknown>,
): Promise<void> => {
socketRef.current?.emit(
"call",
@@ -160,7 +170,7 @@ export const AcquisitionPanel = (
);
};
const getDCPPSearchResults = async (searchQuery) => {
const getDCPPSearchResults = async (searchQuery: { issueName: string }) => {
const manualQuery = {
query: {
pattern: `${searchQuery.issueName}`,
@@ -249,7 +259,7 @@ export const AcquisitionPanel = (
<dl>
<dt>
<div className="mb-1">
{hubs?.data.map((value, idx: string) => (
{hubs?.data.map((value: HubData, idx: number) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
@@ -260,19 +270,19 @@ export const AcquisitionPanel = (
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
{airDCPPSearchInfo.query?.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
{airDCPPSearchInfo.query?.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
{airDCPPSearchInfo.query?.file_type}
</span>
</dd>
</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"
onClick={() =>
download(
airDCPPSearchInstance.id,
airDCPPSearchInstance.id ?? "",
id,
comicObjectId,
name,

View File

@@ -1,17 +1,31 @@
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 {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
} = props.configuration;
return (
<Select
components={{ Placeholder }}
<Select<ActionOption, false>
placeholder={
<span className="inline-flex flex-row items-center gap-2 pt-1">
<div className="w-6 h-6">

View File

@@ -4,7 +4,19 @@ import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { map } from "lodash";
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 (
<div className="overflow-x-auto w-fit mt-6">
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">

View File

@@ -2,41 +2,52 @@ import React, { ReactElement, useCallback, useState } from "react";
import { fetchMetronResource } from "../../../actions/metron.actions";
import Creatable from "react-select/creatable";
import { withAsyncPaginate } from "react-select-async-paginate";
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
interface AsyncSelectPaginateProps {
metronResource: string;
placeholder?: string;
export interface AsyncSelectPaginateProps {
metronResource?: string;
placeholder?: string | React.ReactNode;
value?: object;
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 => {
const [value, setValue] = useState(null);
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({
method: "GET",
resource: props.metronResource,
resource: props.metronResource || "",
query: {
name: query,
page,
},
});
}, []);
}, [props.metronResource]);
return (
<CreatableAsyncPaginate
SelectComponent={Creatable}
debounceTimeout={200}
isDisabled={isAddingInProgress}
value={props.value}
loadOptions={loadData}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loadOptions={loadData as any}
placeholder={props.placeholder}
// onCreateOption={onCreateOption}
onChange={props.onChange}
// cacheUniqs={[cacheUniq]}
additional={{
page: 1,
}}

View File

@@ -68,15 +68,15 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// Hide "match on Comic Vine" when there are no raw file details — matching
// requires file metadata to seed the search query.
const Placeholder = components.Placeholder;
const filteredActionOptions = filter(actionOptions, (item) => {
const filteredActionOptions: ActionOption[] = actionOptions.filter((item) => {
if (isUndefined(rawFileDetails)) {
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) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
@@ -190,7 +190,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
</div>

View File

@@ -8,6 +8,15 @@ import type { ComicVineDetailsProps } from "../../types";
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
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 (
<div className="text-slate-500 dark:text-gray-400">
<div className="">
@@ -15,10 +24,9 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
imageUrl={data.volumeInformation.image?.thumb_url}
orientation={"cover-only"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<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-sm">
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 className="text-sm">
ComicVine Issue ID
@@ -52,7 +60,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
{/* Publisher details */}
<div className="ml-8">
Published by{" "}
<span>{data.volumeInformation.publisher.name}</span>
<span>{data.volumeInformation.publisher?.name}</span>
<div>
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">
@@ -68,16 +76,11 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
<span>{data.issue_number}</span>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
{!isUndefined(detectedIssueType) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
{detectedIssueType.displayName}
</span>
</div>
) : data.resource_type ? (
@@ -92,6 +95,7 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
data.description &&
convert(data.description, {
baseElements: {
selectors: ["p"],

View File

@@ -1,8 +1,18 @@
import React, { useCallback } from "react";
import { Form, Field } from "react-final-form";
import Collapsible from "react-collapsible";
import { ValidationErrors } from "final-form";
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
*
@@ -12,8 +22,8 @@ import { fetchComicVineMatches } from "../../actions/fileops.actions";
* <ComicVineSearchForm data={rawFileDetails} />
* )
*/
export const ComicVineSearchForm = (data) => {
const onSubmit = useCallback((value) => {
export const ComicVineSearchForm = (props: ComicVineSearchFormProps) => {
const onSubmit = useCallback((value: SearchFormValues) => {
const userInititatedQuery = {
inferredIssueDetails: {
name: value.issueName,
@@ -24,8 +34,8 @@ export const ComicVineSearchForm = (data) => {
};
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
}, []);
const validate = () => {
return true;
const validate = (_values: SearchFormValues): ValidationErrors | undefined => {
return undefined;
};
const MyForm = () => (

View File

@@ -33,7 +33,7 @@ type DownloadTickData = {
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
bundleId,
}): ReactElement | null => {
const socketRef = useRef<Socket>();
const socketRef = useRef<Socket | undefined>(undefined);
const [tick, setTick] = useState<DownloadTickData | null>(null);
useEffect(() => {
const socket = useStore.getState().getSocket("manual");

View File

@@ -1,7 +1,7 @@
import React, { useEffect, ReactElement, useState, useMemo } from "react";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { AirDCPPBundles } from "./AirDCPPBundles";
import { TorrentDownloads } from "./TorrentDownloads";
import { TorrentDownloads, TorrentData } from "./TorrentDownloads";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
@@ -32,7 +32,7 @@ export interface TorrentDetails {
export const DownloadsPanel = (): ReactElement | null => {
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [infoHashes, setInfoHashes] = useState<string[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
const [torrentDetails, setTorrentDetails] = useState<TorrentData[]>([]);
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
"directconnect",
);

View File

@@ -7,12 +7,37 @@ import axios from "axios";
import { useGetComicByIdQuery } from "../../graphql/generated";
import type { MatchResultProps } from "../../types";
const handleBrokenImage = (e) => {
e.target.src = "http://localhost:3050/dist/img/noimage.svg";
const handleBrokenImage = (e: React.SyntheticEvent<HTMLImageElement>) => {
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) => {
const applyCVMatch = async (match, comicObjectId) => {
const applyCVMatch = async (match: ComicVineMatch, comicObjectId: string) => {
try {
const response = await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,

View File

@@ -33,7 +33,7 @@ export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
}) => (
<>
<div>
<ComicVineSearchForm data={rawFileDetails} />
<ComicVineSearchForm rawFileDetails={rawFileDetails} />
</div>
<div className="border-slate-500 border rounded-lg p-2 mt-3">

View File

@@ -1,20 +1,41 @@
import React, { ReactElement, Suspense, useState } from "react";
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 [active, setActive] = useState(filteredTabs[0].id);
// Use controlled state if provided, otherwise use internal state
const currentActive = activeTab !== undefined ? activeTab : active;
const handleSetActive = activeTab !== undefined ? setActiveTab : setActive;
const handleSetActive = (id: number) => {
if (setActiveTab) {
setActiveTab(id);
} else {
setActive(id);
}
};
return (
<>
<div className="hidden sm:block mt-7 mb-3 w-fit">
<div className="border-b border-gray-200">
<nav className="flex gap-6" aria-label="Tabs">
{filteredTabs.map(({ id, name, icon }) => (
{filteredTabs.map(({ id, name, icon }: TabItem) => (
<a
key={id}
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
@@ -48,7 +69,7 @@ export const TabControls = (props): ReactElement => {
</div>
</div>
<Suspense fallback={null}>
{filteredTabs.map(({ id, content }) => (
{filteredTabs.map(({ id, content }: TabItem) => (
<React.Fragment key={id}>
{currentActive === id ? content : null}
</React.Fragment>

View File

@@ -139,7 +139,7 @@ export const ArchiveOperations = (props: { data: any }): ReactElement => {
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
// sliding panel init
const contentForSlidingPanel: Record<string, { content: () => JSX.Element }> = {
const contentForSlidingPanel: Record<string, { content: () => React.ReactElement }> = {
imageAnalysis: {
content: () => {
return (

View File

@@ -2,11 +2,48 @@ import React from "react";
import dayjs from "dayjs";
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;
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 (
<dl className="mt-5 dark:text-slate-200 text-slate-600">
<dt className="text-lg">{torrent.name}</dt>

View File

@@ -10,7 +10,31 @@ import { isEmpty, isNil } from "lodash";
import ellipsize from "ellipsize";
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;
// Initialize searchTerm with issueName from props
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
});
const mutation = useMutation({
mutationFn: async (newTorrent) =>
mutationFn: async (newTorrent: TorrentDownloadPayload) =>
axios.post(`${QBITTORRENT_SERVICE_BASE_URI}/addTorrent`, newTorrent),
onSuccess: async (data) => {
onSuccess: async () => {
// Torrent added successfully
},
});
const searchIndexer = (values) => {
const searchIndexer = (values: SearchFormValues) => {
setSearchTerm({ issueName: values.issueName }); // Update searchTerm based on the form submission
};
const downloadTorrent = (evt) => {
const newTorrent = {
const downloadTorrent = (downloadUrl: string) => {
const newTorrent: TorrentDownloadPayload = {
comicObjectId,
torrentToDownload: evt,
torrentToDownload: downloadUrl,
};
mutation.mutate(newTorrent);
};
@@ -125,7 +149,7 @@ export const TorrentSearchPanel = (props) => {
</tr>
</thead>
<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}>
<td className="px-3 py-3 text-gray-700 dark:text-slate-300 text-md">
<p>{ellipsize(result.fileName, 90)}</p>

View File

@@ -1,59 +1,49 @@
import React, { ReactElement, useEffect, useState } from "react";
import { getTransfers } from "../../actions/airdcpp.actions";
import { isEmpty, isNil, isUndefined } from "lodash";
import { isEmpty, isNil } from "lodash";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import MetadataPanel from "../shared/MetadataPanel";
import type { DownloadsProps } from "../../types";
import { useStore } from "../../store";
export const Downloads = (props: DownloadsProps): ReactElement => {
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { settings, socket },
} = airDCPPConfiguration;
// const dispatch = useDispatch();
interface BundleData {
rawFileDetails?: Record<string, unknown>;
inferredMetadata?: Record<string, unknown>;
acquisition?: {
directconnect?: {
downloads?: Array<{
name: string;
size: number;
type: { str: string };
bundleId: string;
}>;
};
};
sourcedMetadata?: {
locg?: unknown;
comicvine?: unknown;
};
issueName?: string;
url?: string;
}
// const airDCPPTransfers = useSelector(
// (state: RootState) => state.airdcpp.transfers,
// );
// const issueBundles = useSelector(
// (state: RootState) => state.airdcpp.issue_bundles,
// );
const [bundles, setBundles] = useState([]);
// Make the call to get all transfers from AirDC++
export const Downloads = (_props: DownloadsProps): ReactElement => {
// Using Zustand store for socket management
const getSocket = useStore((state) => state.getSocket);
const [bundles, setBundles] = useState<BundleData[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Initialize socket connection and load data
useEffect(() => {
if (!isUndefined(socket) && !isEmpty(settings)) {
dispatch(
getTransfers(socket, {
username: `${settings.directConnect.client.host.username}`,
password: `${settings.directConnect.client.host.password}`,
}),
);
const socket = getSocket();
if (socket) {
// Socket is connected, we could fetch transfers here
// For now, just set loading to false since we don't have direct access to Redux state
setIsLoading(false);
}
}, [socket]);
}, [getSocket]);
useEffect(() => {
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) ? (
return !isNil(bundles) && bundles.length > 0 ? (
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<section className="section">
<h1 className="title">Downloads</h1>
@@ -84,16 +74,16 @@ export const Downloads = (props: DownloadsProps): ReactElement => {
</tr>
</thead>
<tbody>
{bundle.acquisition.directconnect.downloads.map(
(bundle, idx) => {
{bundle.acquisition?.directconnect?.downloads?.map(
(download, idx: number) => {
return (
<tr key={idx}>
<td>{bundle.name}</td>
<td>{bundle.size}</td>
<td>{bundle.type.str}</td>
<td>{download.name}</td>
<td>{download.size}</td>
<td>{download.type.str}</td>
<td>
<span className="tag is-warning">
{bundle.bundleId}
{download.bundleId}
</span>
</td>
</tr>

View File

@@ -7,10 +7,17 @@ import { searchIssue } from "../../actions/fileops.actions";
import MetadataPanel from "../shared/MetadataPanel";
import type { GlobalSearchBarProps } from "../../types";
interface AppRootState {
fileOps: {
librarySearchResultsFormatted: Record<string, unknown>[];
};
}
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(
(state: RootState) => state.fileOps.librarySearchResultsFormatted,
(state: AppRootState) => state.fileOps.librarySearchResultsFormatted,
);
const performSearch = useCallback(

View File

@@ -18,12 +18,42 @@ import { Link } from "react-router-dom";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
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) => {
const data = useSelector(
(state: RootState) => state.fileOps.recentComics.docs,
(state: AppRootState) => state.fileOps.recentComics.docs,
);
const pageTotal = useSelector(
(state: RootState) => state.fileOps.recentComics.totalDocs,
(state: AppRootState) => state.fileOps.recentComics.totalDocs,
);
const breakpointColumnsObj = {
default: 5,
@@ -42,20 +72,20 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
{data.map(({ _id, rawFileDetails, sourcedMetadata }) => {
{data.map(({ _id, rawFileDetails, sourcedMetadata }: ComicDoc) => {
let imagePath = "";
let comicName = "";
if (!isEmpty(rawFileDetails.cover)) {
if (rawFileDetails && !isEmpty(rawFileDetails.cover)) {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${removeLeadingPeriod(
rawFileDetails.cover.filePath,
rawFileDetails.cover?.filePath || '',
)}`,
);
imagePath = escapePoundSymbol(encodedFilePath);
comicName = rawFileDetails.name;
} else if (!isNil(sourcedMetadata)) {
comicName = rawFileDetails.name || '';
} else if (!isNil(sourcedMetadata) && sourcedMetadata.comicvine?.image?.small_url) {
imagePath = sourcedMetadata.comicvine.image.small_url;
comicName = sourcedMetadata.comicvine.name;
comicName = sourcedMetadata.comicvine?.name || '';
}
const titleElement = (
<Link to={"/comic/details/" + _id}>
@@ -71,7 +101,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
title={comicName ? titleElement : null}
>
<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">
<img
src="/src/client/assets/img/cvlogo.svg"
@@ -85,7 +115,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
<i className="fas fa-adjust" />
</span>
)}
{!isUndefined(sourcedMetadata.comicvine.volumeInformation) &&
{sourcedMetadata?.comicvine?.volumeInformation?.description &&
!isEmpty(
detectIssueTypes(
sourcedMetadata.comicvine.volumeInformation.description,
@@ -94,8 +124,7 @@ export const LibraryGrid = (libraryGridProps: LibraryGridProps) => {
<span className="tag is-warning ml-1">
{
detectIssueTypes(
sourcedMetadata.comicvine.volumeInformation
.description,
sourcedMetadata.comicvine.volumeInformation.description || '',
).displayName
}
</span>

View File

@@ -3,7 +3,11 @@ import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
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;
return (
<Form

View File

@@ -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 { getWeeklyPullList } from "../../actions/comicinfo.actions";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
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 => {
// const pullListComics = useSelector(
// (state: RootState) => state.comicInfo.pullList,
// );
// Placeholder for pull list comics - would come from API/store
const [pullListComics, setPullListComics] = useState<PullListComic[] | null>(null);
useEffect(() => {
// TODO: Implement pull list fetching
// dispatch(
// getWeeklyPullList({
// startDate: "2023-7-28",
@@ -31,7 +43,7 @@ export const PullList = (): ReactElement => {
id: "comicDetails",
minWidth: 450,
accessorKey: "issue",
cell: (row) => {
cell: (row: CellContext<PullListComic, PullListComic["issue"]>) => {
const item = row.getValue();
return (
<div className="columns">

View File

@@ -1,6 +1,5 @@
import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
@@ -16,17 +15,35 @@ import {
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
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 => {
const queryClient = useQueryClient();
const formData = {
search: "",
};
const [comicVineMetadata, setComicVineMetadata] = useState({});
const [comicVineMetadata, setComicVineMetadata] = useState<{
sourceName?: string;
comicData?: ComicData;
}>({});
const [selectedResource, setSelectedResource] = useState("volume");
const { t } = useTranslation();
const handleResourceChange = (value) => {
const handleResourceChange = (value: string) => {
setSelectedResource(value);
};
@@ -62,6 +79,11 @@ export const Search = ({}: SearchPageProps): ReactElement => {
comicObject,
markEntireVolumeWanted,
resourceType,
}: {
source: string;
comicObject: any;
markEntireVolumeWanted: boolean;
resourceType: string;
}) => {
let volumeInformation = {};
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 });
const createDescriptionMarkup = (html) => {
const createDescriptionMarkup = (html: string) => {
return { __html: html };
};
const onSubmit = async (values) => {
const onSubmit = async (values: { search: string }) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
@@ -269,7 +291,7 @@ export const Search = ({}: SearchPageProps): ReactElement => {
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{comicVineSearchResults.data.results.map((result) => {
{comicVineSearchResults?.data?.results?.map((result: ComicData) => {
return result.resource_type === "issue" ? (
<div
key={result.id}
@@ -286,8 +308,8 @@ export const Search = ({}: SearchPageProps): ReactElement => {
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
{!isEmpty(result.volume?.name) ? (
result.volume?.name
) : (
<span className="is-size-3">No Name</span>
)}
@@ -305,18 +327,18 @@ export const Search = ({}: SearchPageProps): ReactElement => {
{result.api_detail_url}
</a>
<p className="text-sm">
{ellipsize(
{result.description ? ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
) : ''}
</p>
<div className="mt-2">
<PopoverButton
content={`This will add ${result.volume.name} to your wanted list.`}
content={`This will add ${result.volume?.name || 'this issue'} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
@@ -407,14 +429,14 @@ export const Search = ({}: SearchPageProps): ReactElement => {
{/* description */}
<p className="text-sm">
{ellipsize(
{result.description ? ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
) : ''}
</p>
<div className="mt-2">
<PopoverButton

View File

@@ -3,14 +3,21 @@ import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";
import { getServiceStatus } from "../../actions/fileops.actions";
interface AppRootState {
fileOps: {
libraryServiceStatus: unknown;
};
}
export const ServiceStatuses = (): ReactElement => {
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(() => {
dispatch(getServiceStatus());
}, []);
}, [dispatch]);
return (
<div className="is-clearfix">
<div className="mt-4">

View File

@@ -38,16 +38,21 @@ export const AirDCPPHubsForm = (): ReactElement => {
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
});
let hubList: any[] = [];
interface HubOption {
value: string;
label: string;
}
let hubList: HubOption[] = [];
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,
label: identity.name,
}));
}
const mutation = useMutation({
mutationFn: async (values) =>
mutationFn: async (values: Record<string, unknown>) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
@@ -69,13 +74,24 @@ export const AirDCPPHubsForm = (): ReactElement => {
},
});
const validate = async (values) => {
const errors = {};
const validate = async (values: Record<string, unknown>) => {
const errors: Record<string, string> = {};
// Add any validation logic here if needed
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 />;
};
@@ -155,7 +171,7 @@ export const AirDCPPHubsForm = (): ReactElement => {
</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">
{settings?.data.directConnect?.client.hubs.map(
({ value, label }) => (
({ value, label }: HubOption) => (
<div key={value}>
<div>{label}</div>
<span className="is-size-7">{value}</span>

View File

@@ -1,7 +1,24 @@
import React, { ReactElement } from "react";
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
const { settings } = settingsObject;
interface AirDCPPSessionInfo {
_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 (
<div>
<span className="flex items-center mt-10 mb-4">

View File

@@ -17,8 +17,16 @@ export const AirDCPPSettingsForm = () => {
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
});
interface HostConfig {
hostname: string;
port: string;
username: string;
password: string;
protocol: string;
}
// Fetch session information
const fetchSessionInfo = (host) => {
const fetchSessionInfo = (host: HostConfig) => {
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
};
@@ -34,7 +42,7 @@ export const AirDCPPSettingsForm = () => {
// Handle setting update and subsequent AirDC++ initialization
const { mutate } = useMutation({
mutationFn: (values) => {
mutationFn: (values: Record<string, unknown>) => {
return axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: values,
settingsKey: "directConnect",
@@ -50,12 +58,13 @@ export const AirDCPPSettingsForm = () => {
},
});
const deleteSettingsMutation = useMutation(() =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
);
const deleteSettingsMutation = useMutation({
mutationFn: () =>
axios.post("http://localhost:3000/api/settings/saveSettings", {
settingsPayload: {},
settingsKey: "directConnect",
}),
});
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};

View File

@@ -4,9 +4,13 @@ import { Form, Field } from "react-final-form";
import { PROWLARR_SERVICE_BASE_URI } from "../../../constants/endpoints";
import axios from "axios";
export const ProwlarrSettingsForm = (props) => {
interface ProwlarrSettingsFormProps {
// Add props here if needed
}
export const ProwlarrSettingsForm = (_props: ProwlarrSettingsFormProps) => {
const { data } = useQuery({
queryFn: async (): any => {
queryFn: async () => {
return await axios({
url: `${PROWLARR_SERVICE_BASE_URI}/getIndexers`,
method: "POST",

View File

@@ -3,7 +3,7 @@ import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
import axios from "axios";
export const QbittorrentConnectionForm = (): ReactElement => {
export const QbittorrentConnectionForm = (): ReactElement | null => {
const queryClient = new QueryClient();
// fetch settings
const { data, isLoading, isError } = useQuery({
@@ -28,7 +28,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
});
// Update action using a mutation
const { mutate } = useMutation({
mutationFn: async (values) =>
mutationFn: async (values: Record<string, unknown>) =>
await axios({
url: `http://localhost:3000/api/settings/saveSettings`,
method: "POST",
@@ -77,6 +77,7 @@ export const QbittorrentConnectionForm = (): ReactElement => {
</>
);
}
return null;
};
export default QbittorrentConnectionForm;

View File

@@ -10,6 +10,19 @@ import settingsObject from "../../constants/settings/settingsMenu.json";
import { isUndefined, map } from "lodash";
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 => {
const [active, setActive] = useState("gen-db");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
@@ -62,70 +75,70 @@ export const Settings = (props: SettingsProps): ReactElement => {
overflow-hidden"
>
<div className="px-4 py-6 overflow-y-auto">
{map(settingsObject, (settingObject, idx) => (
<div
key={idx}
className="mb-6 text-slate-700 dark:text-slate-300"
>
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
{settingObject.category.toUpperCase()}
</h3>
{!isUndefined(settingObject.children) && (
<ul>
{map(settingObject.children, (item, idx) => {
const isOpen = expanded[item.id];
return (
<li key={idx} className="mb-1">
<div
onClick={() => toggleExpanded(item.id)}
className={`cursor-pointer flex justify-between items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
item.id === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
<span
onClick={() => setActive(item.id.toString())}
className="flex-1"
{map(settingsObject as SettingsCategory[], (settingObject, idx) => (
<div
key={idx}
className="mb-6 text-slate-700 dark:text-slate-300"
>
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 tracking-wide mb-3">
{settingObject.category.toUpperCase()}
</h3>
{!isUndefined(settingObject.children) && (
<ul>
{map(settingObject.children, (item: SettingsMenuItem, idx) => {
const isOpen = expanded[String(item.id)];
return (
<li key={idx} className="mb-1">
<div
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 ${
String(item.id) === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
{item.displayName}
</span>
{!isUndefined(item.children) && (
<span className="text-xs opacity-60">
{isOpen ? "" : "+"}
<span
onClick={() => setActive(String(item.id))}
className="flex-1"
>
{item.displayName}
</span>
{!isUndefined(item.children) && (
<span className="text-xs opacity-60">
{isOpen ? "" : "+"}
</span>
)}
</div>
{!isUndefined(item.children) && isOpen && (
<ul className="pl-4 mt-1">
{map(item.children, (subItem: 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>
{!isUndefined(item.children) && isOpen && (
<ul className="pl-4 mt-1">
{map(item.children, (subItem) => (
<li key={subItem.id} className="mb-1">
<a
onClick={() =>
setActive(subItem.id.toString())
}
className={`cursor-pointer flex items-center px-1 py-1 rounded-md transition-colors hover:bg-white/50 dark:hover:bg-slate-700 ${
subItem.id.toString() === active
? "font-semibold text-blue-600 dark:text-blue-400"
: ""
}`}
>
{subItem.displayName}
</a>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</aside>
</div>

View File

@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
import axios from "axios";
export const SystemSettingsForm = (): ReactElement => {
const { mutate: flushDb, isLoading } = useMutation({
const { mutate: flushDb, isPending } = useMutation({
mutationFn: async () => {
await axios({
url: `http://localhost:3000/api/library/flushDb`,

View File

@@ -8,10 +8,33 @@ import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import { escapePoundSymbol } from "../../shared/utils/formatting.utils";
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 comicBooks = useSelector(
(state: RootState) => state.comicInfo.comicBooksDetails,
(state: LocalRootState) => state.comicInfo?.comicBooksDetails,
);
useEffect(() => {
dispatch(getComicBooksDetailsByIds(props.matches));

View File

@@ -2,7 +2,7 @@ import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
import React, { ReactElement, useState, useCallback } from "react";
import { useParams } from "react-router";
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 { Card } from "../shared/Carda";
import SlidingPane from "react-sliding-pane";
@@ -14,38 +14,87 @@ import {
} from "../../constants/endpoints";
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
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [matches, setMatches] = useState([]);
const [storyArcsData, setStoryArcsData] = useState([]);
const [matches, setMatches] = useState<MatchItem[]>([]);
const [storyArcsData, setStoryArcsData] = useState<StoryArc[]>([]);
const [active, setActive] = useState(1);
// sliding panel init
const contentForSlidingPanel = {
const contentForSlidingPanel: ContentForSlidingPanel = {
potentialMatchesInLibrary: {
content: () => {
const ids = map(matches, partialRight(pick, "_id"));
const matchIds = ids.map((id: any) => id._id);
{
/* return <PotentialLibraryMatches matches={matchIds} />; */
}
const matchIds = ids.map((id: MatchItem) => id._id).filter((id): id is string => !!id);
return <PotentialLibraryMatches matches={matchIds} />;
},
},
};
// sliding panel handlers
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches) => {
const openPotentialLibraryMatchesPanel = useCallback((potentialMatches: MatchItem[]) => {
setSlidingPanelContentId("potentialMatchesInLibrary");
setMatches(potentialMatches);
setVisible(true);
}, []);
// const analyzeIssues = useCallback((issues) => {
// dispatch(analyzeLibrary(issues));
// }, []);
//
// Function to analyze issues (commented out but typed for future use)
const analyzeIssues = useCallback((issues: IssueData[]) => {
// dispatch(analyzeLibrary(issues));
console.log("Analyzing issues:", issues);
}, []);
const { comicObjectId } = useParams<{ comicObjectId: string }>();
@@ -83,7 +132,7 @@ const VolumeDetails = (props): ReactElement => {
// get story arcs
const useGetStoryArcs = () => {
return useMutation({
mutationFn: async (comicObject) =>
mutationFn: async (comicObject: ComicObjectData) =>
axios({
url: `${COMICVINE_SERVICE_URI}/getResource`,
method: "POST",
@@ -93,7 +142,7 @@ const VolumeDetails = (props): ReactElement => {
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
},
}),
onSuccess: (data) => {
onSuccess: (data: { data: { results: StoryArc[] } }) => {
setStoryArcsData(data?.data.results);
},
});
@@ -111,13 +160,13 @@ const VolumeDetails = (props): ReactElement => {
const IssuesInVolume = () => (
<>
{!isUndefined(issuesForSeries) ? (
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
<div className="button" onClick={() => analyzeIssues(issuesForSeries?.data || [])}>
Analyze Library
</div>
) : null}
<>
{isSuccess &&
issuesForSeries.data.map((issue) => {
issuesForSeries.data.map((issue: IssueData) => {
return (
<>
<Card
@@ -157,7 +206,7 @@ const VolumeDetails = (props): ReactElement => {
</article>
<div className="flex flex-wrap">
{isSuccess &&
issuesForSeries?.data.map((issue) => {
issuesForSeries?.data.map((issue: IssueData) => {
return (
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
<div className="flex flex-row gap-4 mb-2">
@@ -170,11 +219,11 @@ const VolumeDetails = (props): ReactElement => {
<div className="w-3/4">
<p className="text-xl">{issue.name}</p>
<p className="text-sm">
{convert(issue.description, {
{issue.description ? convert(issue.description, {
baseElements: {
selectors: ["p"],
},
})}
}) : ''}
</p>
</div>
</div>
@@ -216,9 +265,9 @@ const VolumeDetails = (props): ReactElement => {
{!isEmpty(storyArcsData) && status === "success" && (
<>
<ul>
{storyArcsData.map((storyArc) => {
{storyArcsData.map((storyArc: StoryArc, idx: number) => {
return (
<li>
<li key={idx}>
<span className="text-lg">{storyArc?.name}</span>
</li>
);
@@ -355,7 +404,7 @@ const VolumeDetails = (props): ReactElement => {
width={"600px"}
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
(contentForSlidingPanel as ContentForSlidingPanel)[slidingPanelContentId]?.content()}
</SlidingPane>
</div>
</>

View File

@@ -1,5 +1,4 @@
import React, { ReactElement, useEffect, useMemo } from "react";
import { searchIssue } from "../../actions/fileops.actions";
import React, { ReactElement, useMemo } from "react";
import Card from "../shared/Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
@@ -8,8 +7,45 @@ import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
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 {
data: volumes,
@@ -34,17 +70,18 @@ export const Volumes = (props): ReactElement => {
queryKey: ["volumes"],
});
const columnData = useMemo(
(): any => [
(): ColumnDef<VolumeSourceData, unknown>[] => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorFn: (row) => row,
cell: (row): any => {
const comicObject = row.getValue();
size: 450,
accessorFn: (row: VolumeSourceData) => row,
cell: (info: CellContext<VolumeSourceData, VolumeSourceData>) => {
const comicObject = info.getValue();
const {
_source: { sourcedMetadata },
} = comicObject;
const description = sourcedMetadata.comicvine.volumeInformation.description || '';
return (
<div className="flex flex-row gap-3 mt-5">
<Link to={`/volume/details/${comicObject._id}`}>
@@ -61,9 +98,9 @@ export const Volumes = (props): ReactElement => {
{sourcedMetadata.comicvine.volumeInformation.name}
</div>
<p>
{ellipsize(
{description ? ellipsize(
convert(
sourcedMetadata.comicvine.volumeInformation.description,
description,
{
baseElements: {
selectors: ["p"],
@@ -71,7 +108,7 @@ export const Volumes = (props): ReactElement => {
},
),
180,
)}
) : ''}
</p>
</div>
</div>
@@ -84,9 +121,8 @@ export const Volumes = (props): ReactElement => {
{
header: "Downloads",
accessorKey: "_source.acquisition.directconnect",
align: "right",
cell: (props) => {
const row = props.getValue();
cell: (props: CellContext<VolumeSourceData, unknown[] | undefined>) => {
const row = props.getValue() || [];
return (
<div
style={{
@@ -105,16 +141,16 @@ export const Volumes = (props): ReactElement => {
{
header: "Publisher",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (props): any => {
cell: (props: CellContext<VolumeSourceData, VolumeInformation>) => {
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",
accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
cell: (props: CellContext<VolumeSourceData, number>) => {
const row = props.getValue();
return (
<div className="mt-5">

View File

@@ -1,12 +1,39 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import SearchBar from "../Library/SearchBar";
import React, { ReactElement } from "react";
import T2Table from "../shared/T2Table";
import MetadataPanel from "../shared/MetadataPanel";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
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 {
data: wantedComics,
isSuccess,
@@ -39,9 +66,9 @@ export const WantedComics = (props): ReactElement => {
{
header: "Details",
id: "comicDetails",
minWidth: 350,
accessorFn: (data) => data,
cell: (value) => {
size: 350,
accessorFn: (data: WantedSourceData) => data,
cell: (value: CellContext<WantedSourceData, WantedSourceData>) => {
const row = value.getValue()._source;
return row && <MetadataPanel data={row} />;
},
@@ -53,17 +80,14 @@ export const WantedComics = (props): ReactElement => {
columns: [
{
header: "Files",
align: "right",
accessorKey: "_source.acquisition",
cell: (props) => {
const {
directconnect: { downloads },
} = props.getValue();
cell: (props: CellContext<WantedSourceData, AcquisitionData | undefined>) => {
const acquisition = props.getValue();
const downloads = acquisition?.directconnect?.downloads || [];
return (
<div
style={{
display: "flex",
// flexDirection: "column",
justifyContent: "center",
}}
>
@@ -78,17 +102,21 @@ export const WantedComics = (props): ReactElement => {
header: "Download Details",
id: "downloadDetails",
accessorKey: "_source.acquisition",
cell: (data) => (
<ol>
{data.getValue().directconnect.downloads.map((download, idx) => {
return (
<li className="is-size-7" key={idx}>
{download.name}
</li>
);
})}
</ol>
),
cell: (data: CellContext<WantedSourceData, AcquisitionData | undefined>) => {
const acquisition = data.getValue();
const downloads = acquisition?.directconnect?.downloads || [];
return (
<ol>
{downloads.map((download: DownloadItem, idx: number) => {
return (
<li className="is-size-7" key={idx}>
{download.name}
</li>
);
})}
</ol>
);
},
},
{
header: "Type",

View File

@@ -1,12 +1,25 @@
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 width = 559;
const height = 200;
const pixelRatio = window.devicePixelRatio;
const canvas = useRef(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const context = canvas.current?.getContext("2d");

View File

@@ -6,8 +6,8 @@ interface ICardProps {
orientation: string;
imageUrl?: string;
hasDetails?: boolean;
title?: PropTypes.ReactElementLike | null;
children?: PropTypes.ReactNodeLike;
title?: React.ReactNode;
children?: React.ReactNode;
borderColorClass?: string;
backgroundColor?: string;
cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing";

View File

@@ -3,11 +3,17 @@ import { Form, Field } from "react-final-form";
import { hostNameValidator } from "../../../shared/utils/validator.utils";
import { isEmpty } from "lodash";
interface ConnectionFormProps {
initialData?: Record<string, unknown>;
submitHandler: (values: Record<string, unknown>) => void;
formHeading: string;
}
export const ConnectionForm = ({
initialData,
submitHandler,
formHeading,
}): ReactElement => {
}: ConnectionFormProps): ReactElement => {
return (
<>
<Form

View File

@@ -1,21 +1,25 @@
import React, { useRef, useState } from "react";
import React, { useRef, useState, Dispatch, SetStateAction } from "react";
import { format } from "date-fns";
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 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 [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const classNames: ClassNames = {
...styles,
head: "custom-head",
};
// Use styles without casting - let TypeScript infer
const classNames = styles as unknown as Record<string, string>;
const buttonRef = useRef<HTMLButtonElement>(null);
const { x, y, reference, floating, strategy, refs, update } = useFloating({
const { refs, floatingStyles, strategy, update } = useFloating({
placement: "bottom-end",
middleware: [offset(10), flip()],
strategy: "absolute",
@@ -33,11 +37,11 @@ export const DatePickerDialog = (props) => {
}
};
const handleDaySelect = (date) => {
const handleDaySelect = (date: Date | undefined) => {
setSelected(date);
if (date) {
setter(format(date, "yyyy/MM/dd"));
apiAction();
apiAction?.();
closePopper();
} else {
setter("");
@@ -46,7 +50,7 @@ export const DatePickerDialog = (props) => {
return (
<div>
<div ref={reference}>
<div ref={refs.setReference}>
<button
ref={buttonRef}
type="button"
@@ -69,10 +73,10 @@ export const DatePickerDialog = (props) => {
}}
>
<div
ref={floating}
ref={refs.setFloating}
style={{
position: strategy,
zIndex: "999",
...floatingStyles,
zIndex: 999,
borderRadius: "10px",
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
}}

View File

@@ -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) => {
const inlineStyles = {
const inlineStyles: CSSProperties = {
opacity: faded ? "0.2" : "1",
transformOrigin: "0 0",
minHeight: index === 0 ? 300 : 300,
maxWidth: 200,
gridRowStart: index === 0 ? "span" : null,
gridColumnStart: index === 0 ? "span" : null,
gridRowStart: index === 0 ? "span" : undefined,
gridColumnStart: index === 0 ? "span" : undefined,
backgroundImage: `url("${url}")`,
backgroundSize: "cover",
backgroundPosition: "center",

View File

@@ -8,6 +8,8 @@ import {
DragOverlay,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
@@ -20,22 +22,27 @@ import { SortableCover } from "./SortableCover";
import { Cover } from "./Cover";
import { map } from "lodash";
export const DnD = (data) => {
const [items, setItems] = useState(data.data);
const [activeId, setActiveId] = useState(null);
interface DnDProps {
data: string[];
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));
function handleDragStart(event) {
setActiveId(event.active.id);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event) {
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
if (over && active.id !== over.id) {
setItems((items: string[]) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
@@ -56,13 +63,13 @@ export const DnD = (data) => {
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<Grid columns={4}>
{map(items, (url, index) => {
{map(items, (url: string, index: number) => {
return (
<div>
<SortableCover key={url} url={url} index={index} />
<div key={url}>
<SortableCover url={url} index={index} />
<div
className="mt-2 mb-2"
onClick={(e) => data.onClickHandler(url)}
onClick={() => onClickHandler(url)}
>
<div className="box p-2 control-palette">
<span className="tag is-warning mr-2">{index}</span>

View File

@@ -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 (
<div
style={{

View File

@@ -4,12 +4,17 @@ import { CSS } from "@dnd-kit/utilities";
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 {
attributes,
listeners,
isDragging,
setNodeRef,
transform,
transition,

View File

@@ -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 { useTranslation } from "react-i18next";
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);
// Use destructuring to obtain the reference and floating setters, among other values.
const { x, y, refs, strategy, floatingStyles } = useFloating({

View File

@@ -43,7 +43,7 @@ const router = createBrowserRouter([
path: "comic/details/:comicObjectId",
element: <ComicDetailContainer />,
},
{ path: "import", element: <Import path={"./comics"} /> },
{ path: "import", element: <Import /> },
{ path: "search", element: <Search /> },
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
{ path: "volumes", element: <Volumes /> },

View File

@@ -21,41 +21,43 @@ import { Socket } from "airdcpp-apisocket";
* });
* await socket.connect();
*/
class AirDCPPSocket {
/**
* Creates a new AirDC++ socket connection instance.
*
* @constructor
* @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;
}
interface AirDCPPConfiguration {
protocol: string;
hostname: string;
username: string;
password: string;
}
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 };

View File

@@ -5,14 +5,12 @@
* @module services/api/SearchApi
*/
import SocketService from "../DcppSearchService";
import createAirDCPPSocket, { AirDCPPConfiguration } from "../DcppSearchService";
import {
SearchQuery,
SearchInstance,
PriorityEnum,
SearchResponse,
} from "threetwo-ui-typings";
import SearchConstants from "../../constants/search.constants";
/**
* Configuration data for initiating an AirDC++ search.
@@ -28,6 +26,31 @@ interface SearchData {
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.
*
@@ -55,18 +78,19 @@ function sleep(ms: number): Promise<NodeJS.Timeout> {
* });
*/
export const search = async (data: SearchData) => {
await SocketService.connect();
const instance: SearchInstance = await SocketService.post("search");
const unsubscribe = await SocketService.addListener(
const socket = getSocket();
await socket.connect();
const instance: SearchInstance = await socket.post("search");
const unsubscribe = await socket.addListener(
"search",
"search_hub_searches_sent",
(searchInfo) => {
(searchInfo: SearchInfo) => {
onSearchSent(data, instance, unsubscribe, searchInfo);
},
instance.id,
);
const searchQueueInfo = await SocketService.post(
const searchQueueInfo = await socket.post(
`search/${instance.id}/hub_search`,
data,
);
@@ -84,19 +108,25 @@ export const search = async (data: SearchData) => {
* @param {Function} unsubscribe - Cleanup function to remove the event listener
* @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
await sleep(5000);
const socket = getSocket();
// 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`,
);
) as unknown[] | undefined;
if (results.length > 0) {
if (results && Array.isArray(results) && results.length > 0) {
// We have results, download the best one
// 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),
// target_directory: item.target_directory,
// });
@@ -104,3 +134,5 @@ const onSearchSent = async (item, instance, unsubscribe, searchInfo) => {
// Remove listener for this search instance
unsubscribe();
};
export { initializeSocket };

View File

@@ -53,7 +53,42 @@ import { escapePoundSymbol } from "./formatting.utils";
* });
* // 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 = {
rawFile: {
objectReference: "rawFileDetails",
@@ -87,27 +122,27 @@ export const determineCoverFile = (data): any => {
// Extract ComicVine metadata
if (!isEmpty(data.comicvine)) {
coverFile.comicvine.url = data?.comicvine?.image?.small_url;
coverFile.comicvine.issueName = data.comicvine?.name;
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
coverFile.comicvine.url = data?.comicvine?.image?.small_url || "";
coverFile.comicvine.issueName = data.comicvine?.name || "";
coverFile.comicvine.publisher = data.comicvine?.publisher?.name || "";
}
// Extract raw file details
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) {
if (!isEmpty(data.rawFileDetails) && data.rawFileDetails?.cover?.filePath) {
const encodedFilePath = encodeURI(
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
);
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
coverFile.rawFile.issueName = data.rawFileDetails.name;
coverFile.rawFile.issueName = data.rawFileDetails.name || "";
} else if (!isEmpty(data.rawFileDetails)) {
coverFile.rawFile.issueName = data.rawFileDetails.name;
coverFile.rawFile.issueName = data.rawFileDetails?.name || "";
}
// Extract League of Comic Geeks metadata
if (!isNil(data.locg)) {
coverFile.locg.url = data.locg.cover;
coverFile.locg.issueName = data.locg.name;
coverFile.locg.publisher = data.locg.publisher;
coverFile.locg.url = data.locg.cover || "";
coverFile.locg.issueName = data.locg.name || "";
coverFile.locg.publisher = data.locg.publisher || "";
}
const result = filter(coverFile, (item) => item.url !== "");

View File

@@ -21,7 +21,7 @@ import { transform, isEqual, isObject } from "lodash";
* const obj2 = { a: 1, b: { c: 2, d: 4 } };
* 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);
};
@@ -33,12 +33,12 @@ export const difference = (object, base) => {
* @param {Object} base - The base object to compare against
* @returns {Object} Object containing the differences
*/
const changes = (object, base) => {
return transform(object, (result, value, key) => {
const changes = (object: Record<string, unknown>, base: Record<string, unknown>): Record<string, unknown> => {
return transform(object, (result: Record<string, unknown>, value: unknown, key: string) => {
if (!isEqual(value, base[key])) {
result[key] =
(result as Record<string, unknown>)[key] =
isObject(value) && isObject(base[key])
? changes(value, base[key])
? changes(value as Record<string, unknown>, base[key] as Record<string, unknown>)
: value;
}
});
@@ -81,7 +81,7 @@ export type TraverseFunction<T> = (
* // ".b = [object Object]"
* // "b.c = 2"
*/
export const traverseObject = <T = Record<string, unknown>>(
export const traverseObject = <T extends Record<string, unknown> = Record<string, unknown>>(
object: T,
fn: TraverseFunction<T>,
): 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
* @returns {void}
*/
const traverseInternal = <T = Record<string, unknown>>(
const traverseInternal = <T extends Record<string, unknown>>(
object: T,
fn: TraverseFunction<T>,
scope: string[] = [],
): void => {
Object.entries(object).forEach(([key, value]) => {
fn.apply(this, [object, key, value, scope]);
Object.entries(object as Record<string, unknown>).forEach(([key, value]) => {
fn.apply(undefined, [object, key, value, scope]);
if (value !== null && typeof value === "object") {
traverseInternal(value, fn, scope.concat(key));
traverseInternal(value as T, fn, scope.concat(key));
}
});
};

View File

@@ -27,20 +27,22 @@ export type LibraryStats = {
totalDocuments: number;
comicDirectorySize: {
totalSizeInGB?: number | null;
fileCount?: number;
};
comicsMissingFiles: number;
statistics?: Array<{
issues?: unknown[];
issuesWithComicInfoXML?: unknown[];
issues?: unknown[] | null;
issuesWithComicInfoXML?: unknown[] | null;
fileLessComics?: unknown[] | null;
fileTypes?: Array<{
id: string;
data: unknown[];
}>;
}> | null;
publisherWithMostComicsInLibrary?: Array<{
id: string;
count: number;
}>;
}>;
}> | null;
}> | null;
};
/**

View File

@@ -47,6 +47,53 @@ declare module "*.scss";
*/
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.
* Import from '../../graphql/generated' instead of defining types here.

17
src/client/types/react-masonry-css.d.ts vendored Normal file
View 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;
}

View File

@@ -8,14 +8,14 @@
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": ["node", "vite/client"]
},
"settings": {
"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"
]
}

1525
yarn.lock

File diff suppressed because it is too large Load Diff