diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index 61e4ce83..b67049fc 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -34,6 +34,21 @@ export const SONG_TABLE_COLUMNS = [ // { label: 'Skip', value: TableColumn.SKIP }, ]; +export const ALBUM_TABLE_COLUMNS = [ + { label: 'Row Index', value: TableColumn.ROW_INDEX }, + { label: 'Title', value: TableColumn.TITLE }, + { label: 'Title (Combined)', value: TableColumn.TITLE_COMBINED }, + { label: 'Duration', value: TableColumn.DURATION }, + { label: 'Album Artist', value: TableColumn.ALBUM_ARTIST }, + { label: 'Artist', value: TableColumn.ARTIST }, + { label: 'Genre', value: TableColumn.GENRE }, + { label: 'Year', value: TableColumn.YEAR }, + { label: 'Release Date', value: TableColumn.RELEASE_DATE }, + { label: 'Last Played', value: TableColumn.LAST_PLAYED }, + { label: 'Date Added', value: TableColumn.DATE_ADDED }, + { label: 'Plays', value: TableColumn.PLAY_COUNT }, +]; + interface TableConfigDropdownProps { type: TableType; } diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index ecbff9d4..d237c22b 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -1,8 +1,11 @@ import { ALBUM_CARD_ROWS, + getColumnDefs, + TablePagination, VirtualGridAutoSizerContainer, VirtualInfiniteGrid, VirtualInfiniteGridRef, + VirtualTable, } from '/@/renderer/components'; import { AppRoute } from '/@/renderer/router/routes'; import { ListDisplayType, CardRow, LibraryItem } from '/@/renderer/types'; @@ -16,25 +19,152 @@ import { Album, AlbumListSort } from '/@/renderer/api/types'; import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; import { useQueryClient } from '@tanstack/react-query'; -import { useCurrentServer, useSetAlbumStore, useAlbumListStore } from '/@/renderer/store'; +import { + useCurrentServer, + useSetAlbumStore, + useAlbumListStore, + useAlbumTablePagination, + useSetAlbumTable, + useSetAlbumTablePagination, +} from '/@/renderer/store'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { + BodyScrollEvent, + ColDef, + GridReadyEvent, + IDatasource, + PaginationChangedEvent, +} from '@ag-grid-community/core'; +import { AnimatePresence } from 'framer-motion'; +import debounce from 'lodash/debounce'; interface AlbumListContentProps { gridRef: MutableRefObject; + tableRef: MutableRefObject; } -export const AlbumListContent = ({ gridRef }: AlbumListContentProps) => { +export const AlbumListContent = ({ gridRef, tableRef }: AlbumListContentProps) => { const queryClient = useQueryClient(); const server = useCurrentServer(); const page = useAlbumListStore(); const setPage = useSetAlbumStore(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); - const albumListQuery = useAlbumList({ + const pagination = useAlbumTablePagination(); + const setPagination = useSetAlbumTablePagination(); + const setTable = useSetAlbumTable(); + + const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + + const checkAlbumList = useAlbumList({ limit: 1, startIndex: 0, ...page.filter, }); + const columnDefs: ColDef[] = useMemo( + () => getColumnDefs(page.table.columns), + [page.table.columns], + ); + + const defaultColumnDefs: ColDef = useMemo(() => { + return { + lockPinned: true, + lockVisible: true, + resizable: true, + }; + }, []); + + const onTableReady = useCallback( + (params: GridReadyEvent) => { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = queryKeys.albums.list(server?.id || '', { + limit, + startIndex, + ...page.filter, + }); + + const albumsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumList({ + query: { + limit, + startIndex, + ...page.filter, + }, + server, + signal, + }), + ); + + const albums = api.normalize.albumList(albumsRes, server); + params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined); + }, + rowCount: undefined, + }; + params.api.setDatasource(dataSource); + // params.api.ensureIndexVisible(page.table.scrollOffset || 0, 'top'); + }, + [page.filter, queryClient, server], + ); + + const onTablePaginationChanged = useCallback( + (event: PaginationChangedEvent) => { + if (!isPaginationEnabled || !event.api) return; + + // Scroll to top of page on pagination change + const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage; + event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); + + setPagination({ + itemsPerPage: event.api.paginationGetPageSize(), + totalItems: event.api.paginationGetRowCount(), + totalPages: event.api.paginationGetTotalPages() + 1, + }); + }, + [isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination], + ); + + const handleTableSizeChange = () => { + if (page.table.autoFit) { + tableRef?.current?.api.sizeColumnsToFit(); + } + }; + + const handleTableColumnChange = useCallback(() => { + const { columnApi } = tableRef?.current || {}; + const columnsOrder = columnApi?.getAllGridColumns(); + + if (!columnsOrder) return; + + const columnsInSettings = page.table.columns; + const updatedColumns = []; + for (const column of columnsOrder) { + const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId); + + if (columnInSettings) { + updatedColumns.push({ + ...columnInSettings, + ...(!page.table.autoFit && { + width: column.getColDef().width, + }), + }); + } + } + + setTable({ columns: updatedColumns }); + }, [page.table.autoFit, page.table.columns, setTable, tableRef]); + + const debouncedTableColumnChange = debounce(handleTableColumnChange, 200); + + const handleTableScroll = (e: BodyScrollEvent) => { + const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0)); + setTable({ scrollOffset }); + }; + const fetch = useCallback( async ({ skip, take }: { skip: number; take: number }) => { const queryKey = queryKeys.albums.list(server?.id || '', { @@ -139,31 +269,88 @@ export const AlbumListContent = ({ gridRef }: AlbumListContentProps) => { }, [page.filter.sortBy]); return ( - - - {({ height, width }) => ( - + + {page.display === ListDisplayType.CARD || page.display === ListDisplayType.POSTER ? ( + + {({ height, width }) => ( + + )} + + ) : ( + data.data.id} + infiniteInitialRowCount={checkAlbumList.data?.totalRecordCount || 100} + pagination={isPaginationEnabled} + paginationAutoPageSize={isPaginationEnabled} + paginationPageSize={page.table.pagination.itemsPerPage || 100} + rowBuffer={20} + rowHeight={page.table.rowHeight || 40} + rowModelType="infinite" + rowSelection="multiple" + onBodyScrollEnd={handleTableScroll} + onCellContextMenu={(e) => console.log('context', e)} + onColumnMoved={handleTableColumnChange} + onColumnResized={debouncedTableColumnChange} + onGridReady={onTableReady} + onGridSizeChanged={handleTableSizeChange} + onPaginationChanged={onTablePaginationChanged} /> )} - - + + {isPaginationEnabled && ( + + {page.display === ListDisplayType.TABLE_PAGINATED && ( + + )} + + )} + ); }; diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 9044ab16..6b910e96 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -1,9 +1,10 @@ -import { Flex, Slider } from '@mantine/core'; -import { useQueryClient } from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; import type { ChangeEvent, MouseEvent, MutableRefObject } from 'react'; import { useCallback } from 'react'; +import { IDatasource } from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Flex, Group, Stack } from '@mantine/core'; +import { useQueryClient } from '@tanstack/react-query'; +import debounce from 'lodash/debounce'; import { RiArrowDownSLine, RiFilter3Line, @@ -18,11 +19,16 @@ import { controller } from '/@/renderer/api/controller'; import { queryKeys } from '/@/renderer/api/query-keys'; import { AlbumListSort, ServerType, SortOrder } from '/@/renderer/api/types'; import { + ALBUM_TABLE_COLUMNS, Button, DropdownMenu, + MultiSelect, PageHeader, Popover, SearchInput, + Slider, + Switch, + Text, TextTitle, VirtualInfiniteGridRef, } from '/@/renderer/components'; @@ -36,8 +42,10 @@ import { useCurrentServer, useSetAlbumFilters, useSetAlbumStore, + useSetAlbumTable, + useSetAlbumTablePagination, } from '/@/renderer/store'; -import { ListDisplayType } from '/@/renderer/types'; +import { ListDisplayType, TableColumn } from '/@/renderer/types'; const FILTERS = { jellyfin: [ @@ -82,9 +90,10 @@ const HeaderItems = styled.div` interface AlbumListHeaderProps { gridRef: MutableRefObject; + tableRef: MutableRefObject; } -export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { +export const AlbumListHeader = ({ gridRef, tableRef }: AlbumListHeaderProps) => { const queryClient = useQueryClient(); const server = useCurrentServer(); const setPage = useSetAlbumStore(); @@ -95,6 +104,9 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { const musicFoldersQuery = useMusicFolders(); + const setPagination = useSetAlbumTablePagination(); + const setTable = useSetAlbumTable(); + const sortByLabel = (server?.type && FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filters.sortBy)?.name) || @@ -102,13 +114,16 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { const sortOrderLabel = ORDER.find((o) => o.value === filters.sortOrder)?.name || 'Unknown'; - const setSize = throttle( - (e: number) => - setPage({ - list: { ...page, grid: { ...page.grid, size: e } }, - }), - 200, - ); + const handleItemSize = (e: number) => { + if ( + page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED + ) { + setTable({ rowHeight: e }); + } else { + setPage({ list: { ...page, grid: { ...page.grid, size: e } } }); + } + }; const fetch = useCallback( async (skip: number, take: number, filters: AlbumListFilter) => { @@ -137,18 +152,59 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { const handleFilterChange = useCallback( async (filters: AlbumListFilter) => { - gridRef.current?.scrollTo(0); - gridRef.current?.resetLoadMoreItemsCache(); + if ( + page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED + ) { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; - // Refetching within the virtualized grid may be inconsistent due to it refetching - // using an outdated set of filters. To avoid this, we fetch using the updated filters - // and then set the grid's data here. - const data = await fetch(0, 200, filters); + const queryKey = queryKeys.albums.list(server?.id || '', { + limit, + startIndex, + ...filters, + }); - if (!data?.items) return; - gridRef.current?.setItemData(data.items); + const albumsRes = await queryClient.fetchQuery(queryKey, async ({ signal }) => + api.controller.getAlbumList({ + query: { + limit, + startIndex, + ...filters, + }, + server, + signal, + }), + ); + + const albums = api.normalize.albumList(albumsRes, server); + params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || undefined); + }, + rowCount: undefined, + }; + tableRef.current?.api.setDatasource(dataSource); + tableRef.current?.api.purgeInfiniteCache(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + + if (page.display === ListDisplayType.TABLE_PAGINATED) { + setPagination({ currentPage: 0 }); + } + } else { + gridRef.current?.scrollTo(0); + gridRef.current?.resetLoadMoreItemsCache(); + + // Refetching within the virtualized grid may be inconsistent due to it refetching + // using an outdated set of filters. To avoid this, we fetch using the updated filters + // and then set the grid's data here. + const data = await fetch(0, 200, filters); + + if (!data?.items) return; + gridRef.current?.setItemData(data.items); + } }, - [gridRef, fetch], + [page.display, tableRef, setPagination, server, queryClient, gridRef, fetch], ); const handleSetSortBy = useCallback( @@ -194,14 +250,7 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { const handleSetViewType = useCallback( (e: MouseEvent) => { if (!e.currentTarget?.value) return; - const type = e.currentTarget.value; - if (type === ListDisplayType.CARD) { - setPage({ list: { ...page, display: ListDisplayType.CARD } }); - } else if (type === ListDisplayType.POSTER) { - setPage({ list: { ...page, display: ListDisplayType.POSTER } }); - } else { - setPage({ list: { ...page, display: ListDisplayType.TABLE } }); - } + setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } }); }, [page, setPage], ); @@ -213,6 +262,39 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters); }, 500); + const handleTableColumns = (values: TableColumn[]) => { + const existingColumns = page.table.columns; + + if (values.length === 0) { + return setTable({ + columns: [], + }); + } + + // If adding a column + if (values.length > existingColumns.length) { + const newColumn = { column: values[values.length - 1], width: 100 }; + + setTable({ columns: [...existingColumns, newColumn] }); + } else { + // If removing a column + const removed = existingColumns.filter((column) => !values.includes(column.column)); + const newColumns = existingColumns.filter((column) => !removed.includes(column)); + + setTable({ columns: newColumns }); + } + + return tableRef.current?.api.sizeColumnsToFit(); + }; + + const handleAutoFitColumns = (e: ChangeEvent) => { + setTable({ autoFit: e.currentTarget.checked }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + return ( @@ -239,15 +321,6 @@ export const AlbumListHeader = ({ gridRef }: AlbumListHeaderProps) => { - Item size - - - - Display type { Poster - List + Table + + Table (paginated) + + + Item size + + + + {(page.display === ListDisplayType.TABLE || + page.display === ListDisplayType.TABLE_PAGINATED) && ( + <> + Table Columns + + + column.column)} + width={300} + onChange={handleTableColumns} + /> + + Auto Fit Columns + + + + + + )}