🔨 Added missing file statuses on Dashboard and Library

This commit is contained in:
2026-03-09 19:55:16 -04:00
parent 20336e5569
commit a217d447fa
7 changed files with 131 additions and 43 deletions

View File

@@ -42,6 +42,7 @@ export const RecentlyImported = (
sourcedMetadata, sourcedMetadata,
canonicalMetadata, canonicalMetadata,
inferredMetadata, inferredMetadata,
importStatus,
} = comic; } = comic;
// Parse sourced metadata (GraphQL returns as strings) // Parse sourced metadata (GraphQL returns as strings)
@@ -63,7 +64,7 @@ export const RecentlyImported = (
!isUndefined(comicvine) && !isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation); !isUndefined(comicvine.volumeInformation);
const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo); const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo);
const isMissingFile = isNil(rawFileDetails); const isMissingFile = importStatus?.isRawFileMissing === true;
const cardState = isMissingFile const cardState = isMissingFile
? "missing" ? "missing"
: (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported"; : (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported";
@@ -131,7 +132,7 @@ export const RecentlyImported = (
)} )}
</div> </div>
{/* Raw file presence */} {/* Raw file presence */}
{isNil(rawFileDetails) && ( {isMissingFile && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2"> <span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" /> <i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span> </span>

View File

@@ -314,7 +314,7 @@ export const Library = (): ReactElement => {
columns={missingFilesColumns} columns={missingFilesColumns}
sourceData={missingFilesData?.getComicBooks?.docs ?? []} sourceData={missingFilesData?.getComicBooks?.docs ?? []}
rowClickHandler={navigateToMissingComicDetail} rowClickHandler={navigateToMissingComicDetail}
getRowClassName={() => "bg-card-missing/40"} getRowClassName={() => "bg-card-missing/40 hover:bg-card-missing/20"}
paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }} paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }}
> >
<FilterDropdown /> <FilterDropdown />
@@ -328,6 +328,11 @@ export const Library = (): ReactElement => {
columns={columns} columns={columns}
sourceData={searchResults?.hits.hits} sourceData={searchResults?.hits.hits}
rowClickHandler={navigateToComicDetail} rowClickHandler={navigateToComicDetail}
getRowClassName={(row) =>
missingIdSet.has(row.original._id)
? "bg-card-missing/40 hover:bg-card-missing/20"
: "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"
}
paginationHandlers={{ nextPage, previousPage }} paginationHandlers={{ nextPage, previousPage }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -104,11 +104,22 @@ const renderCard = (props: ICardProps): ReactElement => {
case "vertical-2": case "vertical-2":
return ( return (
<div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}> <div className={`block rounded-md max-w-64 h-fit shadow-md shadow-white-400 ${getCardStateClass(props.cardState) || "bg-gray-200 dark:bg-slate-500"}`}>
<img <div className="relative">
alt="Home" {props.imageUrl ? (
src={props.imageUrl} <img
className="rounded-t-md object-cover" alt="Home"
/> src={props.imageUrl}
className="rounded-t-md object-cover"
/>
) : (
<div className="rounded-t-md h-48 bg-gray-100 dark:bg-slate-600" />
)}
{props.cardState === "missing" && (
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-card-missing/70">
<i className="icon-[solar--file-broken-bold] w-16 h-16 text-red-500" />
</div>
)}
</div>
{props.title ? ( {props.title ? (
<div className="px-3 pt-3 mb-2"> <div className="px-3 pt-3 mb-2">

View File

@@ -34,11 +34,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{ {
name: "rawFileDetails", name: "rawFileDetails",
content: () => ( content: () => (
<dl className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}> <dl
className={`${isMissing ? "bg-card-missing dark:bg-card-missing" : "bg-card-imported dark:bg-card-imported"} dark:text-slate-800 p-2 sm:p-3 rounded-lg`}
>
<dt className="flex items-center gap-2"> <dt className="flex items-center gap-2">
{isMissing && (
<i className="icon-[solar--file-remove-broken] w-4 h-4 text-red-600 shrink-0"></i>
)}
<p className="text-sm sm:text-lg">{issueName}</p> <p className="text-sm sm:text-lg">{issueName}</p>
</dt> </dt>
<dd className="text-xs sm:text-sm"> <dd className="text-xs sm:text-sm">
@@ -87,6 +86,13 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span> </span>
)} )}
{/* Missing file Icon */}
{isMissing && (
<span className="pr-2 pt-1" title="File backing this comic is missing">
<i className="icon-[solar--file-remove-broken] w-5 h-5 text-red-600 shrink-0"></i>
</span>
)}
{/* Uncompressed version available? */} {/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && ( {rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400"> <span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
@@ -188,7 +194,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
return ( return (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-5 my-3">
<div className="w-32 sm:w-56 lg:w-52 shrink-0"> <div className="w-32 sm:w-56 lg:w-52 shrink-0">
<Card <Card
imageUrl={url} imageUrl={url}
orientation={"cover-only"} orientation={"cover-only"}

View File

@@ -1,4 +1,4 @@
import React, { ReactElement, useMemo, useState } from "react"; import React, { ReactElement, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -8,19 +8,38 @@ import {
PaginationState, PaginationState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
/**
* Props for {@link T2Table}.
*/
interface T2TableProps { interface T2TableProps {
/** Row data to render. */
sourceData?: unknown[]; sourceData?: unknown[];
/** Total number of records across all pages, used for pagination display. */
totalPages?: number; totalPages?: number;
/** Column definitions (TanStack Table {@link ColumnDef} array). */
columns?: unknown[]; columns?: unknown[];
/** Callbacks for navigating between pages. */
paginationHandlers?: { paginationHandlers?: {
nextPage?(...args: unknown[]): unknown; nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown; previousPage?(...args: unknown[]): unknown;
}; };
/** Called with the TanStack row object when a row is clicked. */
rowClickHandler?(...args: unknown[]): unknown; rowClickHandler?(...args: unknown[]): unknown;
/** Returns additional CSS classes for a given row (e.g. for highlight states). */
getRowClassName?(row: any): string; getRowClassName?(row: any): string;
/** Optional slot rendered in the toolbar area (e.g. a search input). */
children?: any; children?: any;
} }
/**
* A paginated data table with a two-row sticky header.
*
* The header rounds its corners only while stuck to the top of the scroll
* container, detected via {@link IntersectionObserver} on a sentinel element
* placed immediately before the table. The second header row's `top` offset
* is measured from the DOM at mount time so the two rows stay flush regardless
* of font size or padding changes.
*/
export const T2Table = (tableOptions: T2TableProps): ReactElement => { export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const { const {
sourceData, sourceData,
@@ -31,6 +50,27 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
getRowClassName, getRowClassName,
} = tableOptions; } = tableOptions;
const sentinelRef = useRef<HTMLDivElement>(null);
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
const [isSticky, setIsSticky] = useState(false);
const [firstRowHeight, setFirstRowHeight] = useState(0);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => setIsSticky(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (firstHeaderRowRef.current)
setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height);
}, []);
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({ const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 1, pageIndex: 1,
pageSize: 15, pageSize: 15,
@@ -44,10 +84,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
[pageIndex, pageSize], [pageIndex, pageSize],
); );
/** /** Advances to the next page and notifies the parent via {@link T2TableProps.paginationHandlers}. */
* Pagination control to move forward one page
* @returns void
*/
const goToNextPage = () => { const goToNextPage = () => {
setPagination({ setPagination({
pageIndex: pageIndex + 1, pageIndex: pageIndex + 1,
@@ -56,10 +93,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
nextPage(pageIndex, pageSize); nextPage(pageIndex, pageSize);
}; };
/** /** Goes back one page and notifies the parent via {@link T2TableProps.paginationHandlers}. */
* Pagination control to move backward one page
* @returns void
**/
const goToPreviousPage = () => { const goToPreviousPage = () => {
setPagination({ setPagination({
pageIndex: pageIndex - 1, pageIndex: pageIndex - 1,
@@ -115,26 +149,33 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
</div> </div>
</div> </div>
<div ref={sentinelRef} />
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100"> <table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
<thead className="sticky top-0 z-10 bg-white dark:bg-slate-900"> <thead>
{table.getHeaderGroups().map((headerGroup, groupIndex) => ( {table.getHeaderGroups().map((headerGroup, groupIndex) => {
<tr key={headerGroup.id}> return (
{headerGroup.headers.map((header, index) => ( <tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
<th {headerGroup.headers.map((header) => (
key={header.id} <th
colSpan={header.colSpan} key={header.id}
className="px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left text-gray-500 dark:text-slate-400 border-b border-gray-300 dark:border-slate-700" colSpan={header.colSpan}
> style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
{header.isPlaceholder className={[
? null 'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
: flexRender( 'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
header.column.columnDef.header, groupIndex === 0
header.getContext(), ? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
)} : `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`,
</th> ].join(' ')}
))} >
</tr> {header.isPlaceholder
))} ? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
);
})}
</thead> </thead>
<tbody> <tbody>
@@ -142,7 +183,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
<tr <tr
key={row.id} key={row.id}
onClick={() => rowClickHandler(row)} onClick={() => rowClickHandler(row)}
className={`border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : ""}`} className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 align-top"> <td key={cell.id} className="px-3 py-2 align-top">

View File

@@ -28,6 +28,16 @@ export type AcquisitionSourceInput = {
wanted?: InputMaybe<Scalars['Boolean']['input']>; wanted?: InputMaybe<Scalars['Boolean']['input']>;
}; };
export type AddTorrentInput = {
comicObjectId: Scalars['ID']['input'];
torrentToDownload: Scalars['String']['input'];
};
export type AddTorrentResult = {
__typename?: 'AddTorrentResult';
result?: Maybe<Scalars['JSON']['output']>;
};
export type AppSettings = { export type AppSettings = {
__typename?: 'AppSettings'; __typename?: 'AppSettings';
bittorrent?: Maybe<BittorrentSettings>; bittorrent?: Maybe<BittorrentSettings>;
@@ -405,6 +415,7 @@ export type ImportStats = {
export type ImportStatus = { export type ImportStatus = {
__typename?: 'ImportStatus'; __typename?: 'ImportStatus';
isImported?: Maybe<Scalars['Boolean']['output']>; isImported?: Maybe<Scalars['Boolean']['output']>;
isRawFileMissing?: Maybe<Scalars['Boolean']['output']>;
matchedResult?: Maybe<MatchedResult>; matchedResult?: Maybe<MatchedResult>;
tagged?: Maybe<Scalars['Boolean']['output']>; tagged?: Maybe<Scalars['Boolean']['output']>;
}; };
@@ -609,6 +620,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']>;
/** Add a torrent to qBittorrent */
addTorrent?: Maybe<AddTorrentResult>;
analyzeImage: ImageAnalysisResult; analyzeImage: ImageAnalysisResult;
applyComicVineMatch: Comic; applyComicVineMatch: Comic;
bulkResolveMetadata: Array<Comic>; bulkResolveMetadata: Array<Comic>;
@@ -626,6 +639,11 @@ export type Mutation = {
}; };
export type MutationAddTorrentArgs = {
input: AddTorrentInput;
};
export type MutationAnalyzeImageArgs = { export type MutationAnalyzeImageArgs = {
imageFilePath: Scalars['String']['input']; imageFilePath: Scalars['String']['input'];
}; };
@@ -768,6 +786,7 @@ export type PublisherStats = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
_empty?: Maybe<Scalars['String']['output']>;
analyzeMetadataConflicts: Array<MetadataConflict>; analyzeMetadataConflicts: Array<MetadataConflict>;
bundles: Array<Bundle>; bundles: Array<Bundle>;
comic?: Maybe<Comic>; comic?: Maybe<Comic>;
@@ -1265,7 +1284,7 @@ export type GetRecentComicsQueryVariables = Exact<{
}>; }>;
export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null }> } }; export type GetRecentComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicConnection', totalCount: number, comics: Array<{ __typename?: 'Comic', id: string, createdAt?: string | null, updatedAt?: string | null, inferredMetadata?: { __typename?: 'InferredMetadata', issue?: { __typename?: 'Issue', name?: string | null, number?: number | null, year?: string | null, subtitle?: string | null } | null } | null, rawFileDetails?: { __typename?: 'RawFileDetails', name?: string | null, extension?: string | null, cover?: { __typename?: 'Cover', filePath?: string | null } | null, archive?: { __typename?: 'Archive', uncompressed?: boolean | null } | null } | null, sourcedMetadata?: { __typename?: 'SourcedMetadata', comicvine?: string | null, comicInfo?: string | null, locg?: { __typename?: 'LOCGMetadata', name?: string | null, publisher?: string | null, cover?: string | null } | null } | null, canonicalMetadata?: { __typename?: 'CanonicalMetadata', title?: { __typename?: 'MetadataField', value?: string | null } | null, series?: { __typename?: 'MetadataField', value?: string | null } | null, issueNumber?: { __typename?: 'MetadataField', value?: string | null } | null, publisher?: { __typename?: 'MetadataField', value?: string | null } | null } | null, importStatus?: { __typename?: 'ImportStatus', isRawFileMissing?: boolean | null } | null }> } };
export type GetWantedComicsQueryVariables = Exact<{ export type GetWantedComicsQueryVariables = Exact<{
paginationOptions: PaginationOptionsInput; paginationOptions: PaginationOptionsInput;
@@ -1706,6 +1725,9 @@ export const GetRecentComicsDocument = `
value value
} }
} }
importStatus {
isRawFileMissing
}
createdAt createdAt
updatedAt updatedAt
} }

View File

@@ -93,6 +93,9 @@ query GetRecentComics($limit: Int) {
value value
} }
} }
importStatus {
isRawFileMissing
}
createdAt createdAt
updatedAt updatedAt
} }