🔢 Fix for filesize on disk on Dashboard

This commit is contained in:
2026-03-09 21:33:21 -04:00
parent 71d7034d01
commit d506cf8ba8
4 changed files with 173 additions and 194 deletions

View File

@@ -1,105 +1,106 @@
import React, { ReactElement } from "react"; import React, { ReactElement } from "react";
import { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header"; import Header from "../shared/Header";
import { GetLibraryStatisticsQuery } from "../../graphql/generated"; import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type LibraryStatisticsProps = { type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
stats: GetLibraryStatisticsQuery['getLibraryStatistics']; comicDirectorySize: DirectorySize;
}; };
export const LibraryStatistics = ( /** Props for {@link LibraryStatistics}. */
props: LibraryStatisticsProps, interface LibraryStatisticsProps {
): ReactElement => { stats: Stats | null | undefined;
const { stats } = props; }
/**
* Displays a snapshot of library metrics: total comic files, tagging coverage,
* file-type breakdown, and the publisher with the most issues.
*
* Returns `null` when `stats` is absent or the statistics array is empty.
*/
export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => {
if (!stats) return null;
const facet = stats.statistics?.[0];
if (!facet) return null;
const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary, fileLessComics } = facet;
const topPublisher = publisherWithMostComicsInLibrary?.[0];
return ( return (
<div className="mt-5"> <div className="mt-5">
<Header <Header
headerContent="Your Library In Numbers" headerContent="Your Library In Numbers"
subHeaderContent={ subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
<span className="text-md">A brief snapshot of your library.</span>
}
iconClassNames="fa-solid fa-binoculars mr-2" iconClassNames="fa-solid fa-binoculars mr-2"
/> />
<div className="mt-3"> <div className="mt-3 flex flex-row gap-5">
<div className="flex flex-row gap-5"> {/* Total records in database */}
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center"> <div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Library size</dt> <dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-green-600 md:text-5xl"> <dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files {stats.totalDocuments} comics
</dd> </dd>
{props.stats.comicDirectorySize?.fileCount && (
<dd>
<span className="text-2xl text-green-600">
{props.stats.comicDirectorySize.fileCount} comic files
</span>
</dd>
)}
</div>
{/* comicinfo and comicvine tagged issues */}
<div className="flex flex-col gap-4">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issues) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issues?.length || 0}
</span>{" "}
tagged with ComicVine
</div>
)}
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">
{props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0}
</span>{" "}
<span className="tag is-warning has-text-weight-bold mr-2 ml-1">
with ComicInfo.xml
</span>
</div>
)}
</div>
<div className="">
{!isUndefined(props.stats.statistics) &&
!isEmpty(props.stats.statistics?.[0]?.fileTypes) &&
map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => {
return (
<span
key={idx}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
>
{fileType.data.length} {fileType.id}
</span>
);
})}
</div>
{/* file types */}
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
{/* publisher with most issues */}
{!isUndefined(props.stats.statistics) &&
!isEmpty(
props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0],
) && (
<>
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.id
}
</span>
{" has the most issues "}
<span className="">
{
props.stats.statistics?.[0]
?.publisherWithMostComicsInLibrary?.[0]?.count
}
</span>
</>
)}
</div>
</div> </div>
{/* Missing files */}
{fileLessComics && fileLessComics.length > 0 && (
<div className="flex flex-col rounded-lg bg-red-100 dark:bg-red-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Missing files</dt>
<dd className="text-3xl text-red-600 md:text-5xl">
{fileLessComics.length}
</dd>
</div>
)}
{/* Disk space consumed */}
{stats.comicDirectorySize.totalSizeInGB != null && (
<div className="flex flex-col rounded-lg bg-green-100 dark:bg-green-200 px-4 py-6 text-center">
<dt className="text-lg font-medium text-gray-500">Size on disk</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB
</dd>
</div>
)}
{/* Tagging coverage */}
<div className="flex flex-col gap-4">
{issues && issues.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">{issues.length}</span>
tagged with ComicVine
</div>
)}
{issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && (
<div className="flex flex-col h-fit rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center">
<span className="text-xl">{issuesWithComicInfoXML.length}</span>
with ComicInfo.xml
</div>
)}
</div>
{/* File-type breakdown */}
{fileTypes && fileTypes.length > 0 && (
<div>
{fileTypes.map((ft) => (
<span
key={ft.id}
className="flex flex-col mb-4 h-fit text-xl rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3 text-center"
>
{ft.data.length} {ft.id}
</span>
))}
</div>
)}
{/* Publisher with most issues */}
{topPublisher && (
<div className="flex flex-col h-fit text-lg rounded-lg bg-green-100 dark:bg-green-200 px-4 py-3">
<span>{topPublisher.id}</span>
{" has the most issues "}
<span>{topPublisher.count}</span>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,55 +1,51 @@
import React, { ReactElement, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
import { import {
ColumnDef, ColumnDef,
Row,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
useReactTable, useReactTable,
PaginationState, PaginationState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
/** /** Props for {@link T2Table}. */
* Props for {@link T2Table}. interface T2TableProps<TData> {
*/
interface T2TableProps {
/** Row data to render. */ /** Row data to render. */
sourceData?: unknown[]; sourceData?: TData[];
/** Total number of records across all pages, used for pagination display. */ /** Total number of records across all pages, used for pagination display. */
totalPages?: number; totalPages?: number;
/** Column definitions (TanStack Table {@link ColumnDef} array). */ /** Column definitions (TanStack Table {@link ColumnDef} array). */
columns?: unknown[]; columns?: ColumnDef<TData>[];
/** Callbacks for navigating between pages. */ /** Callbacks for navigating between pages. */
paginationHandlers?: { paginationHandlers?: {
nextPage?(...args: unknown[]): unknown; nextPage?(pageIndex: number, pageSize: number): void;
previousPage?(...args: unknown[]): unknown; previousPage?(pageIndex: number, pageSize: number): void;
}; };
/** Called with the TanStack row object when a row is clicked. */ /** Called with the TanStack row object when a row is clicked. */
rowClickHandler?(...args: unknown[]): unknown; rowClickHandler?(row: Row<TData>): void;
/** Returns additional CSS classes for a given row (e.g. for highlight states). */ /** Returns additional CSS classes for a given row (e.g. for highlight states). */
getRowClassName?(row: any): string; getRowClassName?(row: Row<TData>): string;
/** Optional slot rendered in the toolbar area (e.g. a search input). */ /** Optional slot rendered in the toolbar area (e.g. a search input). */
children?: any; children?: ReactNode;
} }
/** /**
* A paginated data table with a two-row sticky header. * A paginated data table with a two-row sticky header.
* *
* The header rounds its corners only while stuck to the top of the scroll * Header stickiness is detected via {@link IntersectionObserver} on a sentinel
* container, detected via {@link IntersectionObserver} on a sentinel element * element placed immediately before the table. The second header row's `top`
* placed immediately before the table. The second header row's `top` offset * offset is measured at mount time so both rows stay flush regardless of font
* is measured from the DOM at mount time so the two rows stay flush regardless * size or padding changes.
* of font size or padding changes.
*/ */
export const T2Table = (tableOptions: T2TableProps): ReactElement => { export const T2Table = <TData,>({
const { sourceData = [],
sourceData, columns = [],
columns, paginationHandlers: { nextPage, previousPage } = {},
paginationHandlers: { nextPage, previousPage }, totalPages = 0,
totalPages, rowClickHandler,
rowClickHandler, getRowClassName,
getRowClassName, children,
} = tableOptions; }: T2TableProps<TData>): ReactElement => {
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null); const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
const [isSticky, setIsSticky] = useState(false); const [isSticky, setIsSticky] = useState(false);
@@ -76,30 +72,18 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
pageSize: 15, pageSize: 15,
}); });
const pagination = useMemo( const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
/** Advances to the next page and notifies the parent via {@link T2TableProps.paginationHandlers}. */ /** Advances to the next page and notifies the parent. */
const goToNextPage = () => { const goToNextPage = () => {
setPagination({ setPagination({ pageIndex: pageIndex + 1, pageSize });
pageIndex: pageIndex + 1, nextPage?.(pageIndex, pageSize);
pageSize,
});
nextPage(pageIndex, pageSize);
}; };
/** Goes back one page and notifies the parent via {@link T2TableProps.paginationHandlers}. */ /** Goes back one page and notifies the parent. */
const goToPreviousPage = () => { const goToPreviousPage = () => {
setPagination({ setPagination({ pageIndex: pageIndex - 1, pageSize });
pageIndex: pageIndex - 1, previousPage?.(pageIndex, pageSize);
pageSize,
});
previousPage(pageIndex, pageSize);
}; };
const table = useReactTable({ const table = useReactTable({
@@ -108,43 +92,37 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
manualPagination: true, manualPagination: true,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1, pageCount: sourceData.length ?? -1,
state: { state: { pagination },
pagination,
},
onPaginationChange: setPagination, onPaginationChange: setPagination,
}); });
return ( return (
<div className="container max-w-fit"> <div className="container max-w-fit">
<div> <div className="flex flex-row gap-2 justify-between mt-6 mb-4">
<div className="flex flex-row gap-2 justify-between mt-6 mb-4"> {children}
{/* Search bar */}
{tableOptions.children}
{/* Pagination controls */} <div className="text-sm text-gray-800 dark:text-slate-200">
<div className="text-sm text-gray-800 dark:text-slate-200"> <div className="mb-1">
<div className="mb-1"> Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
Page {pageIndex} of {Math.ceil(totalPages / pageSize)} </div>
</div> <p className="text-xs text-gray-600 dark:text-slate-400">
<p className="text-xs text-gray-600 dark:text-slate-400"> {totalPages} comics in all
{totalPages} comics in all </p>
</p> <div className="inline-flex flex-row mt-3">
<div className="inline-flex flex-row mt-3"> <button
<button onClick={goToPreviousPage}
onClick={() => goToPreviousPage()} disabled={pageIndex === 1}
disabled={pageIndex === 1} className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600"
className="dark:bg-slate-400 bg-gray-300 rounded-l px-2 py-1 border-r border-slate-600" >
> <i className="icon-[solar--arrow-left-linear] h-5 w-5" />
<i className="icon-[solar--arrow-left-linear] h-5 w-5"></i> </button>
</button> <button
<button onClick={goToNextPage}
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1" disabled={pageIndex > Math.floor(totalPages / pageSize)}
onClick={() => goToNextPage()} className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
disabled={pageIndex > Math.floor(totalPages / pageSize)} >
> <i className="icon-[solar--arrow-right-linear] h-5 w-5" />
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i> </button>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -152,37 +130,35 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
<div ref={sentinelRef} /> <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> <thead>
{table.getHeaderGroups().map((headerGroup, groupIndex) => { {table.getHeaderGroups().map((headerGroup, groupIndex) => (
return ( <tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}> {headerGroup.headers.map((header) => (
{headerGroup.headers.map((header) => ( <th
<th key={header.id}
key={header.id} colSpan={header.colSpan}
colSpan={header.colSpan} style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
style={groupIndex === 1 ? { top: firstRowHeight } : undefined} className={[
className={[ 'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left',
'sticky z-10 px-3 py-2 text-[11px] font-semibold tracking-wide uppercase text-left', 'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900',
'text-gray-500 dark:text-slate-400 bg-white dark:bg-slate-900', groupIndex === 0
groupIndex === 0 ? `top-0 ${isSticky ? 'first:rounded-tl-xl last:rounded-tr-xl' : ''}`
? `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' : ''}`,
: `border-b-2 border-gray-200 dark:border-slate-600 shadow-md ${isSticky ? 'first:rounded-bl-xl last:rounded-br-xl' : ''}`, ].join(' ')}
].join(' ')} >
> {header.isPlaceholder
{header.isPlaceholder ? null
? null : flexRender(header.column.columnDef.header, header.getContext())}
: flexRender(header.column.columnDef.header, header.getContext())} </th>
</th> ))}
))} </tr>
</tr> ))}
);
})}
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row, rowIndex) => ( {table.getRowModel().rows.map((row) => (
<tr <tr
key={row.id} key={row.id}
onClick={() => rowClickHandler(row)} onClick={() => rowClickHandler?.(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"}`} 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) => (

View File

@@ -1302,7 +1302,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A
export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>; export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } }; export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number, totalSizeInGB: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array<string> }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array<string>, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } };
export type GetWeeklyPullListQueryVariables = Exact<{ export type GetWeeklyPullListQueryVariables = Exact<{
input: WeeklyPullListInput; input: WeeklyPullListInput;
@@ -1945,6 +1945,7 @@ export const GetLibraryStatisticsDocument = `
totalDocuments totalDocuments
comicDirectorySize { comicDirectorySize {
fileCount fileCount
totalSizeInGB
} }
statistics { statistics {
fileTypes { fileTypes {

View File

@@ -181,6 +181,7 @@ query GetLibraryStatistics {
totalDocuments totalDocuments
comicDirectorySize { comicDirectorySize {
fileCount fileCount
totalSizeInGB
} }
statistics { statistics {
fileTypes { fileTypes {