🔢 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 { isEmpty, isUndefined, map } from "lodash";
import Header from "../shared/Header";
import { GetLibraryStatisticsQuery } from "../../graphql/generated";
import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated";
type LibraryStatisticsProps = {
stats: GetLibraryStatisticsQuery['getLibraryStatistics'];
type Stats = Omit<GetLibraryStatisticsQuery["getLibraryStatistics"], "comicDirectorySize"> & {
comicDirectorySize: DirectorySize;
};
export const LibraryStatistics = (
props: LibraryStatisticsProps,
): ReactElement => {
const { stats } = props;
/** Props for {@link LibraryStatistics}. */
interface LibraryStatisticsProps {
stats: Stats | null | undefined;
}
/**
* 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 (
<div className="mt-5">
<Header
headerContent="Your Library In Numbers"
subHeaderContent={
<span className="text-md">A brief snapshot of your library.</span>
}
subHeaderContent={<span className="text-md">A brief snapshot of your library.</span>}
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="mt-3">
<div className="flex flex-row gap-5">
<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>
<dd className="text-3xl text-green-600 md:text-5xl">
{props.stats.totalDocuments} files
</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 className="mt-3 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">
<dt className="text-lg font-medium text-gray-500">In database</dt>
<dd className="text-3xl text-green-600 md:text-5xl">
{stats.totalDocuments} comics
</dd>
</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>
);

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 {
ColumnDef,
Row,
flexRender,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
PaginationState,
} from "@tanstack/react-table";
/**
* Props for {@link T2Table}.
*/
interface T2TableProps {
/** Props for {@link T2Table}. */
interface T2TableProps<TData> {
/** Row data to render. */
sourceData?: unknown[];
sourceData?: TData[];
/** Total number of records across all pages, used for pagination display. */
totalPages?: number;
/** Column definitions (TanStack Table {@link ColumnDef} array). */
columns?: unknown[];
columns?: ColumnDef<TData>[];
/** Callbacks for navigating between pages. */
paginationHandlers?: {
nextPage?(...args: unknown[]): unknown;
previousPage?(...args: unknown[]): unknown;
nextPage?(pageIndex: number, pageSize: number): void;
previousPage?(pageIndex: number, pageSize: number): void;
};
/** 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). */
getRowClassName?(row: any): string;
getRowClassName?(row: Row<TData>): string;
/** 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.
*
* 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.
* Header stickiness is detected via {@link IntersectionObserver} on a sentinel
* element placed immediately before the table. The second header row's `top`
* offset is measured at mount time so both rows stay flush regardless of font
* size or padding changes.
*/
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
const {
sourceData,
columns,
paginationHandlers: { nextPage, previousPage },
totalPages,
rowClickHandler,
getRowClassName,
} = tableOptions;
export const T2Table = <TData,>({
sourceData = [],
columns = [],
paginationHandlers: { nextPage, previousPage } = {},
totalPages = 0,
rowClickHandler,
getRowClassName,
children,
}: T2TableProps<TData>): ReactElement => {
const sentinelRef = useRef<HTMLDivElement>(null);
const firstHeaderRowRef = useRef<HTMLTableRowElement>(null);
const [isSticky, setIsSticky] = useState(false);
@@ -76,30 +72,18 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
pageSize: 15,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const pagination = useMemo(() => ({ 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 = () => {
setPagination({
pageIndex: pageIndex + 1,
pageSize,
});
nextPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex + 1, 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 = () => {
setPagination({
pageIndex: pageIndex - 1,
pageSize,
});
previousPage(pageIndex, pageSize);
setPagination({ pageIndex: pageIndex - 1, pageSize });
previousPage?.(pageIndex, pageSize);
};
const table = useReactTable({
@@ -108,43 +92,37 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
pageCount: sourceData.length ?? -1,
state: {
pagination,
},
state: { pagination },
onPaginationChange: setPagination,
});
return (
<div className="container max-w-fit">
<div>
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{/* Search bar */}
{tableOptions.children}
<div className="flex flex-row gap-2 justify-between mt-6 mb-4">
{children}
{/* Pagination controls */}
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={() => goToPreviousPage()}
disabled={pageIndex === 1}
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>
</button>
<button
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
onClick={() => goToNextPage()}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5"></i>
</button>
</div>
<div className="text-sm text-gray-800 dark:text-slate-200">
<div className="mb-1">
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
</div>
<p className="text-xs text-gray-600 dark:text-slate-400">
{totalPages} comics in all
</p>
<div className="inline-flex flex-row mt-3">
<button
onClick={goToPreviousPage}
disabled={pageIndex === 1}
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" />
</button>
<button
onClick={goToNextPage}
disabled={pageIndex > Math.floor(totalPages / pageSize)}
className="dark:bg-slate-400 bg-gray-300 rounded-r px-2 py-1"
>
<i className="icon-[solar--arrow-right-linear] h-5 w-5" />
</button>
</div>
</div>
</div>
@@ -152,37 +130,35 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
<div ref={sentinelRef} />
<table className="table-auto w-full text-sm text-gray-900 dark:text-slate-100">
<thead>
{table.getHeaderGroups().map((headerGroup, groupIndex) => {
return (
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
className={[
'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',
groupIndex === 0
? `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' : ''}`,
].join(' ')}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
);
})}
{table.getHeaderGroups().map((headerGroup, groupIndex) => (
<tr key={headerGroup.id} ref={groupIndex === 0 ? firstHeaderRowRef : undefined}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
style={groupIndex === 1 ? { top: firstRowHeight } : undefined}
className={[
'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',
groupIndex === 0
? `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' : ''}`,
].join(' ')}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, rowIndex) => (
{table.getRowModel().rows.map((row) => (
<tr
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"}`}
>
{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 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<{
input: WeeklyPullListInput;
@@ -1945,6 +1945,7 @@ export const GetLibraryStatisticsDocument = `
totalDocuments
comicDirectorySize {
fileCount
totalSizeInGB
}
statistics {
fileTypes {

View File

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