This repository has been archived on 2025-03-19. You can view files and clone it, but cannot push or open issues or pull requests.
feishin/src/renderer/components/virtual-table/index.tsx

454 lines
16 KiB
TypeScript

import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
import type {
ICellRendererParams,
ValueGetterParams,
IHeaderParams,
ValueFormatterParams,
ColDef,
ColumnMovedEvent,
NewColumnsLoadedEvent,
GridReadyEvent,
GridSizeChangedEvent,
} from '@ag-grid-community/core';
import type { AgGridReactProps } from '@ag-grid-community/react';
import { AgGridReact } from '@ag-grid-community/react';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useClickOutside, useMergedRef } from '@mantine/hooks';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import formatDuration from 'format-duration';
import { generatePath } from 'react-router';
import styled from 'styled-components';
import { AlbumArtistCell } from '/@/renderer/components/virtual-table/cells/album-artist-cell';
import { ArtistCell } from '/@/renderer/components/virtual-table/cells/artist-cell';
import { CombinedTitleCell } from '/@/renderer/components/virtual-table/cells/combined-title-cell';
import { GenericCell } from '/@/renderer/components/virtual-table/cells/generic-cell';
import { GenreCell } from '/@/renderer/components/virtual-table/cells/genre-cell';
import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers/generic-table-header';
import { AppRoute } from '/@/renderer/router/routes';
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
import { TableColumn } from '/@/renderer/types';
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
export * from './table-config-dropdown';
export * from './table-pagination';
export * from './hooks/use-fixed-table-header';
export * from './hooks/use-click-outside-deselect';
const TableWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;
dayjs.extend(relativeTime);
const tableColumns: { [key: string]: ColDef } = {
album: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { isLink: true, position: 'left' }),
colId: TableColumn.ALBUM,
headerName: 'Album',
valueGetter: (params: ValueGetterParams) =>
params.data
? {
link: generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: params.data?.albumId || '',
}),
value: params.data?.album,
}
: undefined,
width: 200,
},
albumArtist: {
cellRenderer: AlbumArtistCell,
colId: TableColumn.ALBUM_ARTIST,
headerName: 'Album Artist',
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.albumArtists : undefined,
width: 150,
},
albumCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.ALBUM_COUNT,
field: 'albumCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Albums',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.albumCount : undefined),
width: 80,
},
artist: {
cellRenderer: ArtistCell,
colId: TableColumn.ARTIST,
headerName: 'Artist',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.artists : undefined),
width: 150,
},
biography: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'left' }),
colId: TableColumn.BIOGRAPHY,
field: 'biography',
headerName: 'Biography',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.biography : ''),
width: 200,
},
bitRate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.BIT_RATE,
field: 'bitRate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => `${params.value} kbps`,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bitRate : undefined),
width: 90,
},
bpm: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.BPM,
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'BPM',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.bpm : undefined),
width: 60,
},
channels: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.CHANNELS,
field: 'channels',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.channels : undefined),
width: 100,
},
comment: {
cellRenderer: GenericCell,
colId: TableColumn.COMMENT,
headerName: 'Note',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.comment : undefined),
width: 150,
},
dateAdded: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DATE_ADDED,
field: 'createdAt',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Date Added',
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.createdAt : undefined),
width: 130,
},
discNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DISC_NUMBER,
field: 'discNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Disc',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.discNumber : undefined),
width: 60,
},
duration: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.DURATION,
field: 'duration',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'duration' }),
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) => formatDuration(params.value * 1000),
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.duration : undefined),
width: 70,
},
genre: {
cellRenderer: GenreCell,
colId: TableColumn.GENRE,
headerName: 'Genre',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.genres : undefined),
width: 100,
},
lastPlayedAt: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.LAST_PLAYED,
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Last Played',
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).fromNow() : '',
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.lastPlayedAt : undefined,
width: 130,
},
path: {
cellRenderer: GenericCell,
colId: TableColumn.PATH,
headerName: 'Path',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.path : undefined),
width: 200,
},
playCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.PLAY_COUNT,
field: 'playCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Plays',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.playCount : undefined),
width: 90,
},
releaseDate: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.RELEASE_DATE,
field: 'releaseDate',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Release Date',
suppressSizeToFit: true,
valueFormatter: (params: ValueFormatterParams) =>
params.value ? dayjs(params.value).format('MMM D, YYYY') : '',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseDate : undefined),
width: 130,
},
releaseYear: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.YEAR,
field: 'releaseYear',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Year',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.releaseYear : undefined),
width: 80,
},
rowIndex: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'right' }),
colId: TableColumn.ROW_INDEX,
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'right', preset: 'rowIndex' }),
suppressSizeToFit: true,
valueGetter: (params) => {
return (params.node?.rowIndex || 0) + 1;
},
width: 65,
},
songCount: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.SONG_COUNT,
field: 'songCount',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Songs',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.songCount : undefined),
width: 80,
},
title: {
cellRenderer: (params: ICellRendererParams) =>
GenericCell(params, { position: 'left', primary: true }),
colId: TableColumn.TITLE,
field: 'name',
headerName: 'Title',
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.name : undefined),
width: 250,
},
titleCombined: {
cellRenderer: CombinedTitleCell,
colId: TableColumn.TITLE_COMBINED,
headerName: 'Title',
initialWidth: 500,
minWidth: 150,
valueGetter: (params: ValueGetterParams) =>
params.data
? {
albumArtists: params.data?.albumArtists,
artists: params.data?.artists,
imagePlaceholderUrl: params.data?.imagePlaceholderUrl,
imageUrl: params.data?.imageUrl,
name: params.data?.name,
rowHeight: params.node?.rowHeight,
type: params.data?.serverType,
}
: undefined,
width: 250,
},
trackNumber: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.TRACK_NUMBER,
field: 'trackNumber',
headerComponent: (params: IHeaderParams) => GenericTableHeader(params, { position: 'center' }),
headerName: 'Track',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data.trackNumber : undefined),
width: 80,
},
userFavorite: {
cellClass: (params) => (params.value ? 'visible ag-cell-favorite' : 'ag-cell-favorite'),
cellRenderer: FavoriteCell,
colId: TableColumn.USER_FAVORITE,
field: 'userFavorite',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userFavorite' }),
headerName: 'Favorite',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.userFavorite : undefined,
width: 50,
},
userRating: {
cellClass: (params) => (params.value?.userRating ? 'visible ag-cell-rating' : 'ag-cell-rating'),
cellRenderer: RatingCell,
colId: TableColumn.USER_RATING,
field: 'userRating',
headerComponent: (params: IHeaderParams) =>
GenericTableHeader(params, { position: 'center', preset: 'userRating' }),
headerName: 'Rating',
suppressSizeToFit: true,
valueGetter: (params: ValueGetterParams) => (params.data ? params.data : undefined),
width: 95,
},
};
export const getColumnDef = (column: TableColumn) => {
return tableColumns[column as keyof typeof tableColumns];
};
export const getColumnDefs = (columns: PersistedTableColumn[]) => {
const columnDefs: ColDef[] = [];
for (const column of columns) {
const presetColumn = tableColumns[column.column as keyof typeof tableColumns];
if (presetColumn) {
columnDefs.push({
...presetColumn,
initialWidth: column.width,
});
}
}
return columnDefs;
};
interface VirtualTableProps extends AgGridReactProps {
autoFitColumns?: boolean;
autoHeight?: boolean;
deselectOnClickOutside?: boolean;
transparentHeader?: boolean;
}
export const VirtualTable = forwardRef(
(
{
autoFitColumns,
deselectOnClickOutside,
autoHeight,
transparentHeader,
onColumnMoved,
onNewColumnsLoaded,
onGridReady,
onGridSizeChanged,
...rest
}: VirtualTableProps,
ref: Ref<AgGridReactType | null>,
) => {
const tableRef = useRef<AgGridReactType | null>(null);
const mergedRef = useMergedRef(ref, tableRef);
const deselectRef = useClickOutside(() => {
if (tableRef?.current?.api && deselectOnClickOutside) {
tableRef?.current?.api?.deselectAll();
}
});
const defaultColumnDefs: ColDef = useMemo(() => {
return {
lockPinned: true,
lockVisible: true,
resizable: true,
};
}, []);
// Auto fit columns on column change
useEffect(() => {
if (!tableRef?.current?.api) return;
if (autoFitColumns && tableRef?.current?.api) {
tableRef?.current?.api?.sizeColumnsToFit?.();
}
}, [autoFitColumns]);
// Reset row heights on row height change
useEffect(() => {
if (!tableRef?.current?.api) return;
tableRef?.current?.api?.resetRowHeights();
tableRef?.current?.api?.redrawRows();
}, [rest.rowHeight]);
const handleColumnMoved = useCallback(
(e: ColumnMovedEvent) => {
if (!e?.api) return;
onColumnMoved?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onColumnMoved],
);
const handleNewColumnsLoaded = useCallback(
(e: NewColumnsLoadedEvent) => {
if (!e?.api) return;
onNewColumnsLoaded?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onNewColumnsLoaded],
);
const handleGridReady = useCallback(
(e: GridReadyEvent) => {
if (!e?.api) return;
onGridReady?.(e);
if (autoHeight) e.api.setDomLayout('autoHeight');
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoHeight, autoFitColumns, onGridReady],
);
const handleGridSizeChanged = useCallback(
(e: GridSizeChangedEvent) => {
if (!e?.api) return;
onGridSizeChanged?.(e);
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
},
[autoFitColumns, onGridSizeChanged],
);
return (
<TableWrapper
ref={deselectRef}
className={
transparentHeader ? 'ag-header-transparent ag-theme-alpine-dark' : 'ag-theme-alpine-dark'
}
>
<AgGridReact
ref={mergedRef}
animateRows
maintainColumnOrder
suppressAsyncEvents
suppressContextMenu
suppressCopyRowsToClipboard
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressScrollOnNewData
blockLoadDebounceMillis={200}
cacheBlockSize={300}
cacheOverflowSize={1}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
headerHeight={36}
rowBuffer={30}
rowSelection="multiple"
{...rest}
onColumnMoved={handleColumnMoved}
onGridReady={handleGridReady}
onGridSizeChanged={handleGridSizeChanged}
onNewColumnsLoaded={handleNewColumnsLoaded}
/>
</TableWrapper>
);
},
);