🔨 ComicDetail grqphQL refactor

This commit is contained in:
2026-03-09 11:44:24 -04:00
parent c392333170
commit 8913e9cd99
5 changed files with 236 additions and 90 deletions

View File

@@ -67,13 +67,8 @@ type ComicDetailProps = {
}; };
/** /**
* Component for displaying the metadata for a comic in greater detail. * Displays full comic detail: cover, file info, action menu, and tabbed panels
* * for metadata, archive operations, and acquisition.
* @component
* @example
* return (
* <ComicDetail/>
* )
*/ */
export const ComicDetail = (data: ComicDetailProps): ReactElement => { export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const { const {
@@ -84,7 +79,6 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
sourcedMetadata: { comicvine, locg, comicInfo }, sourcedMetadata: { comicvine, locg, comicInfo },
acquisition, acquisition,
createdAt, createdAt,
updatedAt,
}, },
userSettings, userSettings,
queryClient, queryClient,
@@ -94,24 +88,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [activeTab, setActiveTab] = useState<number | undefined>(undefined); const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState(""); const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const { comicObjectId } = useParams<{ comicObjectId: string }>(); const { comicObjectId } = useParams<{ comicObjectId: string }>();
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching(); const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
// Modal handlers (currently unused but kept for future use)
const openModal = useCallback((filePath: string) => {
setIsOpen(true);
}, []);
const afterOpenModal = useCallback((things: any) => {
// Modal opened callback
}, []);
const closeModal = useCallback(() => {
setIsOpen(false);
}, []);
// Action event handlers // Action event handlers
const openDrawerWithCVMatches = () => { const openDrawerWithCVMatches = () => {
prepareAndFetchMatches(rawFileDetails, comicvine); prepareAndFetchMatches(rawFileDetails, comicvine);
@@ -224,10 +204,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
<div className="grid"> <div className="grid">
<RawFileDetails <RawFileDetails
data={{ data={{
rawFileDetails: rawFileDetails, rawFileDetails,
inferredMetadata: inferredMetadata, inferredMetadata,
created_at: createdAt, createdAt,
updated_at: updatedAt,
}} }}
> >
{/* action dropdown */} {/* action dropdown */}

View File

@@ -1,12 +1,21 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => { interface ComicVineMatchPanelProps {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData.props; props: {
comicObjectId: string;
comicVineMatches: any[];
queryClient?: any;
onMatchApplied?: () => void;
};
}
/** Displays ComicVine search results or a status message while searching. */
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
const { comicvine } = useStore( const { comicvine } = useStore(
useShallow((state) => ({ useShallow((state) => ({
comicvine: state.comicvine, comicvine: state.comicvine,

View File

@@ -1,55 +1,41 @@
import React, { ReactElement, useCallback, useEffect, useState } from "react"; import React, { ReactElement } from "react";
import { Form, Field } from "react-final-form"; import { Form, Field, FieldRenderProps } from "react-final-form";
import arrayMutators from "final-form-arrays"; import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays"; import { FieldArray } from "react-final-form-arrays";
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate"; import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
export const EditMetadataPanel = (props): ReactElement => { interface EditMetadataPanelProps {
const validate = async () => {}; data: {
name?: string;
[key: string]: any;
};
}
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
);
/** Sliding panel form for manually editing comic metadata fields. */
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
const onSubmit = async () => {}; const onSubmit = async () => {};
const { data } = props;
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
return (
<TextareaAutosize
{...input}
{...rest}
onChange={(value) => input.onChange(value)}
/>
);
};
// const rawFileDetails = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
// );
return ( return (
<> <>
<Form <Form
onSubmit={onSubmit} onSubmit={onSubmit}
validate={validate} mutators={{ ...arrayMutators }}
mutators={{
...arrayMutators,
}}
render={({ render={({
handleSubmit, handleSubmit,
form: { form: {
mutators: { push, pop }, mutators: { push, pop },
}, // injected from final-form-arrays above },
pristine,
form,
submitting,
values,
}) => ( }) => (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Issue Name */} {/* Issue Name */}
@@ -80,7 +66,6 @@ export const EditMetadataPanel = (props): ReactElement => {
<p className="text-xs">Do not enter the first zero</p> <p className="text-xs">Do not enter the first zero</p>
</div> </div>
<div> <div>
{/* year */}
<div className="text-sm">Issue Year</div> <div className="text-sm">Issue Year</div>
<Field <Field
name="issue_year" name="issue_year"
@@ -100,8 +85,6 @@ export const EditMetadataPanel = (props): ReactElement => {
</div> </div>
</div> </div>
{/* page count */}
{/* Description */} {/* Description */}
<div className="mt-2"> <div className="mt-2">
<label className="text-sm">Description</label> <label className="text-sm">Description</label>
@@ -113,7 +96,7 @@ export const EditMetadataPanel = (props): ReactElement => {
/> />
</div> </div>
<hr size="1" /> <hr />
<div className="field is-horizontal"> <div className="field is-horizontal">
<div className="field-label"> <div className="field-label">
@@ -153,7 +136,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div> </div>
</div> </div>
<hr size="1" /> <hr />
{/* Publisher */} {/* Publisher */}
<div className="field is-horizontal"> <div className="field is-horizontal">
@@ -224,7 +207,7 @@ export const EditMetadataPanel = (props): ReactElement => {
</div> </div>
</div> </div>
<hr size="1" /> <hr />
{/* team credits */} {/* team credits */}
<div className="field is-horizontal"> <div className="field is-horizontal">
@@ -302,7 +285,6 @@ export const EditMetadataPanel = (props): ReactElement => {
)) ))
} }
</FieldArray> </FieldArray>
<pre>{JSON.stringify(values, undefined, 2)}</pre>
</form> </form>
)} )}
/> />

View File

@@ -1,29 +1,24 @@
import React, { ReactElement, ReactNode } from "react"; import React, { ReactElement, ReactNode } from "react";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { format, parseISO } from "date-fns"; import { format, parseISO, isValid } from "date-fns";
import { RawFileDetails as RawFileDetailsType } from "../../graphql/generated"; import {
RawFileDetails as RawFileDetailsType,
InferredMetadata,
} from "../../graphql/generated";
type RawFileDetailsProps = { type RawFileDetailsProps = {
data?: { data?: {
rawFileDetails?: RawFileDetailsType; rawFileDetails?: RawFileDetailsType;
inferredMetadata?: { inferredMetadata?: InferredMetadata;
issue?: { createdAt?: string;
year?: string;
name?: string;
number?: number;
subtitle?: string;
};
};
created_at?: string;
updated_at?: string;
}; };
children?: ReactNode; children?: ReactNode;
}; };
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => { export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } = const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
props.data || {};
return ( return (
<> <>
<div className="max-w-2xl ml-5"> <div className="max-w-2xl ml-5">
@@ -97,10 +92,10 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
Import Details Import Details
</dt> </dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400"> <dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{created_at ? ( {createdAt && isValid(parseISO(createdAt)) ? (
<> <>
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "} {format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")} {format(parseISO(createdAt), "h aaaa")}
</> </>
) : "N/A"} ) : "N/A"}
</dd> </dd>

View File

@@ -28,6 +28,13 @@ export type AcquisitionSourceInput = {
wanted?: InputMaybe<Scalars['Boolean']['input']>; wanted?: InputMaybe<Scalars['Boolean']['input']>;
}; };
export type AppSettings = {
__typename?: 'AppSettings';
bittorrent?: Maybe<BittorrentSettings>;
directConnect?: Maybe<DirectConnectSettings>;
prowlarr?: Maybe<ProwlarrSettings>;
};
export type Archive = { export type Archive = {
__typename?: 'Archive'; __typename?: 'Archive';
expandedPath?: Maybe<Scalars['String']['output']>; expandedPath?: Maybe<Scalars['String']['output']>;
@@ -52,6 +59,26 @@ export type AutoMergeSettingsInput = {
onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>; onMetadataUpdate?: InputMaybe<Scalars['Boolean']['input']>;
}; };
export type BittorrentClient = {
__typename?: 'BittorrentClient';
host?: Maybe<HostConfig>;
name?: Maybe<Scalars['String']['output']>;
};
export type BittorrentSettings = {
__typename?: 'BittorrentSettings';
client?: Maybe<BittorrentClient>;
};
export type Bundle = {
__typename?: 'Bundle';
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
size?: Maybe<Scalars['String']['output']>;
speed?: Maybe<Scalars['String']['output']>;
status?: Maybe<Scalars['String']['output']>;
};
export type CanonicalMetadata = { export type CanonicalMetadata = {
__typename?: 'CanonicalMetadata'; __typename?: 'CanonicalMetadata';
ageRating?: Maybe<MetadataField>; ageRating?: Maybe<MetadataField>;
@@ -123,6 +150,11 @@ export type ComicConnection = {
totalCount: Scalars['Int']['output']; totalCount: Scalars['Int']['output'];
}; };
export type ComicVineMatchInput = {
volume: ComicVineVolumeRefInput;
volumeInformation?: InputMaybe<Scalars['JSON']['input']>;
};
export type ComicVineResourceResponse = { export type ComicVineResourceResponse = {
__typename?: 'ComicVineResourceResponse'; __typename?: 'ComicVineResourceResponse';
error: Scalars['String']['output']; error: Scalars['String']['output'];
@@ -143,6 +175,24 @@ export type ComicVineSearchResult = {
offset: Scalars['Int']['output']; offset: Scalars['Int']['output'];
results: Array<SearchResultItem>; results: Array<SearchResultItem>;
status_code: Scalars['Int']['output']; status_code: Scalars['Int']['output'];
total: Scalars['Int']['output'];
};
export type ComicVineVolume = {
__typename?: 'ComicVineVolume';
api_detail_url?: Maybe<Scalars['String']['output']>;
count_of_issues?: Maybe<Scalars['Int']['output']>;
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
image?: Maybe<VolumeImage>;
name?: Maybe<Scalars['String']['output']>;
publisher?: Maybe<Publisher>;
site_detail_url?: Maybe<Scalars['String']['output']>;
start_year?: Maybe<Scalars['String']['output']>;
};
export type ComicVineVolumeRefInput = {
api_detail_url: Scalars['String']['input'];
}; };
export enum ConflictResolutionStrategy { export enum ConflictResolutionStrategy {
@@ -177,10 +227,22 @@ export type DirectConnectBundleInput = {
size?: InputMaybe<Scalars['String']['input']>; size?: InputMaybe<Scalars['String']['input']>;
}; };
export type DirectConnectClient = {
__typename?: 'DirectConnectClient';
airDCPPUserSettings?: Maybe<Scalars['JSON']['output']>;
host?: Maybe<HostConfig>;
hubs?: Maybe<Array<Maybe<Scalars['JSON']['output']>>>;
};
export type DirectConnectInput = { export type DirectConnectInput = {
downloads?: InputMaybe<Array<DirectConnectBundleInput>>; downloads?: InputMaybe<Array<DirectConnectBundleInput>>;
}; };
export type DirectConnectSettings = {
__typename?: 'DirectConnectSettings';
client?: Maybe<DirectConnectClient>;
};
export type DirectorySize = { export type DirectorySize = {
__typename?: 'DirectorySize'; __typename?: 'DirectorySize';
fileCount: Scalars['Int']['output']; fileCount: Scalars['Int']['output'];
@@ -234,6 +296,37 @@ export type GetVolumesInput = {
volumeURI: Scalars['String']['input']; volumeURI: Scalars['String']['input'];
}; };
export type HostConfig = {
__typename?: 'HostConfig';
hostname?: Maybe<Scalars['String']['output']>;
password?: Maybe<Scalars['String']['output']>;
port?: Maybe<Scalars['String']['output']>;
protocol?: Maybe<Scalars['String']['output']>;
username?: Maybe<Scalars['String']['output']>;
};
export type HostInput = {
hostname: Scalars['String']['input'];
password: Scalars['String']['input'];
port: Scalars['String']['input'];
protocol: Scalars['String']['input'];
username: Scalars['String']['input'];
};
export type Hub = {
__typename?: 'Hub';
description?: Maybe<Scalars['String']['output']>;
id?: Maybe<Scalars['Int']['output']>;
name?: Maybe<Scalars['String']['output']>;
userCount?: Maybe<Scalars['Int']['output']>;
};
export type ImageAnalysisResult = {
__typename?: 'ImageAnalysisResult';
analyzedData?: Maybe<Scalars['JSON']['output']>;
colorHistogramData?: Maybe<Scalars['JSON']['output']>;
};
export type ImageUrls = { export type ImageUrls = {
__typename?: 'ImageUrls'; __typename?: 'ImageUrls';
icon_url?: Maybe<Scalars['String']['output']>; icon_url?: Maybe<Scalars['String']['output']>;
@@ -516,6 +609,8 @@ export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
/** Placeholder for future mutations */ /** Placeholder for future mutations */
_empty?: Maybe<Scalars['String']['output']>; _empty?: Maybe<Scalars['String']['output']>;
analyzeImage: ImageAnalysisResult;
applyComicVineMatch: Comic;
bulkResolveMetadata: Array<Comic>; bulkResolveMetadata: Array<Comic>;
forceCompleteSession: ForceCompleteResult; forceCompleteSession: ForceCompleteResult;
importComic: ImportComicResult; importComic: ImportComicResult;
@@ -525,11 +620,23 @@ export type Mutation = {
setMetadataField: Comic; setMetadataField: Comic;
startIncrementalImport: IncrementalImportResult; startIncrementalImport: IncrementalImportResult;
startNewImport: ImportJobResult; startNewImport: ImportJobResult;
uncompressArchive?: Maybe<Scalars['Boolean']['output']>;
updateSourcedMetadata: Comic; updateSourcedMetadata: Comic;
updateUserPreferences: UserPreferences; updateUserPreferences: UserPreferences;
}; };
export type MutationAnalyzeImageArgs = {
imageFilePath: Scalars['String']['input'];
};
export type MutationApplyComicVineMatchArgs = {
comicObjectId: Scalars['ID']['input'];
match: ComicVineMatchInput;
};
export type MutationBulkResolveMetadataArgs = { export type MutationBulkResolveMetadataArgs = {
comicIds: Array<Scalars['ID']['input']>; comicIds: Array<Scalars['ID']['input']>;
}; };
@@ -580,6 +687,13 @@ export type MutationStartNewImportArgs = {
}; };
export type MutationUncompressArchiveArgs = {
comicObjectId: Scalars['ID']['input'];
filePath: Scalars['String']['input'];
options?: InputMaybe<Scalars['JSON']['input']>;
};
export type MutationUpdateSourcedMetadataArgs = { export type MutationUpdateSourcedMetadataArgs = {
comicId: Scalars['ID']['input']; comicId: Scalars['ID']['input'];
metadata: Scalars['String']['input']; metadata: Scalars['String']['input'];
@@ -628,6 +742,17 @@ export type Provenance = {
url?: Maybe<Scalars['String']['output']>; url?: Maybe<Scalars['String']['output']>;
}; };
export type ProwlarrClient = {
__typename?: 'ProwlarrClient';
apiKey?: Maybe<Scalars['String']['output']>;
host?: Maybe<HostConfig>;
};
export type ProwlarrSettings = {
__typename?: 'ProwlarrSettings';
client?: Maybe<ProwlarrClient>;
};
export type Publisher = { export type Publisher = {
__typename?: 'Publisher'; __typename?: 'Publisher';
api_detail_url?: Maybe<Scalars['String']['output']>; api_detail_url?: Maybe<Scalars['String']['output']>;
@@ -644,6 +769,7 @@ export type PublisherStats = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
analyzeMetadataConflicts: Array<MetadataConflict>; analyzeMetadataConflicts: Array<MetadataConflict>;
bundles: Array<Bundle>;
comic?: Maybe<Comic>; comic?: Maybe<Comic>;
comics: ComicConnection; comics: ComicConnection;
/** Fetch resource from Metron API */ /** Fetch resource from Metron API */
@@ -664,13 +790,18 @@ export type Query = {
getVolume: VolumeDetailResponse; getVolume: VolumeDetailResponse;
/** Get weekly pull list from League of Comic Geeks */ /** Get weekly pull list from League of Comic Geeks */
getWeeklyPullList: MetadataPullListResponse; getWeeklyPullList: MetadataPullListResponse;
hubs: Array<Hub>;
previewCanonicalMetadata?: Maybe<CanonicalMetadata>; previewCanonicalMetadata?: Maybe<CanonicalMetadata>;
/** Search ComicVine for volumes, issues, characters, etc. */ /** Search ComicVine for volumes, issues, characters, etc. */
searchComicVine: ComicVineSearchResult; searchComicVine: ComicVineSearchResult;
searchIssue: SearchIssueResult; searchIssue: SearchIssueResult;
searchTorrents: Array<TorrentSearchResult>;
settings?: Maybe<AppSettings>;
torrentJobs?: Maybe<TorrentJob>;
userPreferences?: Maybe<UserPreferences>; userPreferences?: Maybe<UserPreferences>;
/** Advanced volume-based search with scoring and filtering */ /** Advanced volume-based search with scoring and filtering */
volumeBasedSearch: VolumeBasedSearchResponse; volumeBasedSearch: VolumeBasedSearchResponse;
walkFolders: Array<Scalars['String']['output']>;
}; };
@@ -679,6 +810,12 @@ export type QueryAnalyzeMetadataConflictsArgs = {
}; };
export type QueryBundlesArgs = {
comicObjectId: Scalars['ID']['input'];
config?: InputMaybe<Scalars['JSON']['input']>;
};
export type QueryComicArgs = { export type QueryComicArgs = {
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
}; };
@@ -734,6 +871,11 @@ export type QueryGetWeeklyPullListArgs = {
}; };
export type QueryHubsArgs = {
host: HostInput;
};
export type QueryPreviewCanonicalMetadataArgs = { export type QueryPreviewCanonicalMetadataArgs = {
comicId: Scalars['ID']['input']; comicId: Scalars['ID']['input'];
preferences?: InputMaybe<UserPreferencesInput>; preferences?: InputMaybe<UserPreferencesInput>;
@@ -752,6 +894,21 @@ export type QuerySearchIssueArgs = {
}; };
export type QuerySearchTorrentsArgs = {
query: Scalars['String']['input'];
};
export type QuerySettingsArgs = {
settingsKey?: InputMaybe<Scalars['String']['input']>;
};
export type QueryTorrentJobsArgs = {
trigger: Scalars['String']['input'];
};
export type QueryUserPreferencesArgs = { export type QueryUserPreferencesArgs = {
userId?: InputMaybe<Scalars['String']['input']>; userId?: InputMaybe<Scalars['String']['input']>;
}; };
@@ -761,6 +918,12 @@ export type QueryVolumeBasedSearchArgs = {
input: VolumeSearchInput; input: VolumeSearchInput;
}; };
export type QueryWalkFoldersArgs = {
basePathToWalk: Scalars['String']['input'];
extensions?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type RawFileDetails = { export type RawFileDetails = {
__typename?: 'RawFileDetails'; __typename?: 'RawFileDetails';
archive?: Maybe<Archive>; archive?: Maybe<Archive>;
@@ -939,6 +1102,24 @@ export type TeamCredit = {
site_detail_url?: Maybe<Scalars['String']['output']>; site_detail_url?: Maybe<Scalars['String']['output']>;
}; };
export type TorrentJob = {
__typename?: 'TorrentJob';
id?: Maybe<Scalars['String']['output']>;
name?: Maybe<Scalars['String']['output']>;
};
export type TorrentSearchResult = {
__typename?: 'TorrentSearchResult';
downloadUrl?: Maybe<Scalars['String']['output']>;
guid?: Maybe<Scalars['String']['output']>;
indexer?: Maybe<Scalars['String']['output']>;
leechers?: Maybe<Scalars['Int']['output']>;
publishDate?: Maybe<Scalars['String']['output']>;
seeders?: Maybe<Scalars['Int']['output']>;
size?: Maybe<Scalars['Float']['output']>;
title?: Maybe<Scalars['String']['output']>;
};
export type UserPreferences = { export type UserPreferences = {
__typename?: 'UserPreferences'; __typename?: 'UserPreferences';
autoMerge: AutoMergeSettings; autoMerge: AutoMergeSettings;