🔢 Fix for filesize on disk on Dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -181,6 +181,7 @@ query GetLibraryStatistics {
|
||||
totalDocuments
|
||||
comicDirectorySize {
|
||||
fileCount
|
||||
totalSizeInGB
|
||||
}
|
||||
statistics {
|
||||
fileTypes {
|
||||
|
||||
Reference in New Issue
Block a user