diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 813d9238..d8f1ef8f 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -798,7 +798,7 @@ export type DeletePlaylistArgs = { } & BaseEndpointArgs; // Playlist List -export type PlaylistListResponse = BasePaginatedResponse; +export type PlaylistListResponse = BasePaginatedResponse | null | undefined; export enum PlaylistListSort { DURATION = 'duration', diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index e588dcb0..d82a37b0 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -1,29 +1,24 @@ -import { ChangeEvent, MutableRefObject, useCallback, MouseEvent } from 'react'; import { IDatasource } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Flex, Stack, Group, Divider } from '@mantine/core'; +import { Divider, Flex, Group, Stack } from '@mantine/core'; import { useQueryClient } from '@tanstack/react-query'; +import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; import { RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { SortOrder, PlaylistListSort, PlaylistListQuery } from '/@/renderer/api/types'; -import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components'; +import { LibraryItem, PlaylistListQuery, PlaylistListSort, SortOrder } from '/@/renderer/api/types'; +import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; +import { OrderToggleButton } from '/@/renderer/features/shared'; import { useContainerQuery } from '/@/renderer/hooks'; import { PlaylistListFilter, useCurrentServer, - usePlaylistGridStore, + useListStoreActions, usePlaylistListStore, - usePlaylistStoreActions, - useSetPlaylistFilters, - useSetPlaylistStore, - useSetPlaylistTable, - useSetPlaylistTablePagination, } from '/@/renderer/store'; import { ListDisplayType, TableColumn } from '/@/renderer/types'; -import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; -import { OrderToggleButton } from '/@/renderer/features/shared'; const FILTERS = { jellyfin: [ @@ -50,16 +45,12 @@ export const PlaylistListHeaderFilters = ({ gridRef, tableRef, }: PlaylistListHeaderFiltersProps) => { + const pageKey = 'playlist'; const queryClient = useQueryClient(); const server = useCurrentServer(); - const page = usePlaylistListStore(); - const setPage = useSetPlaylistStore(); - const setFilter = useSetPlaylistFilters(); - const setTable = useSetPlaylistTable(); - const setPagination = useSetPlaylistTablePagination(); - const grid = usePlaylistGridStore(); - const { setGrid } = usePlaylistStoreActions(); - const { display } = usePlaylistListStore(); + const { setFilter, setTable, setTablePagination, setGrid, setDisplayType } = + useListStoreActions(); + const { display, filter, table, grid } = usePlaylistListStore({ key: pageKey }); const cq = useContainerQuery(); const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; @@ -68,7 +59,7 @@ export const PlaylistListHeaderFilters = ({ (server?.type && ( FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[] - ).find((f) => f.value === page.filter.sortBy)?.name) || + ).find((f) => f.value === filter.sortBy)?.name) || 'Unknown'; const fetch = useCallback( @@ -109,7 +100,7 @@ export const PlaylistListHeaderFilters = ({ if (isGrid) { gridRef.current?.scrollTo(0); gridRef.current?.resetLoadMoreItemsCache(); - const data = await fetch(0, 200, filters || page.filter); + const data = await fetch(0, 200, filters || filter); if (!data?.items) return; gridRef.current?.setItemData(data.items); } else { @@ -118,7 +109,7 @@ export const PlaylistListHeaderFilters = ({ const limit = params.endRow - params.startRow; const startIndex = params.startRow; - const pageFilters = filters || page.filter; + const pageFilters = filters || filter; const queryKey = queryKeys.playlists.list(server?.id || '', { limit, @@ -152,10 +143,10 @@ export const PlaylistListHeaderFilters = ({ tableRef.current?.api.setDatasource(dataSource); tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.ensureIndexVisible(0, 'top'); - setPagination({ data: { currentPage: 0 } }); + setTablePagination({ data: { currentPage: 0 }, key: pageKey }); } }, - [fetch, gridRef, isGrid, page.filter, queryClient, server, setPagination, tableRef], + [isGrid, gridRef, fetch, filter, tableRef, setTablePagination, server, queryClient], ); const handleSetSortBy = useCallback( @@ -167,9 +158,13 @@ export const PlaylistListHeaderFilters = ({ )?.defaultOrder; const updatedFilters = setFilter({ - sortBy: e.currentTarget.value as PlaylistListSort, - sortOrder: sortOrder || SortOrder.ASC, - }); + data: { + sortBy: e.currentTarget.value as PlaylistListSort, + sortOrder: sortOrder || SortOrder.ASC, + }, + itemType: LibraryItem.PLAYLIST, + key: pageKey, + }) as PlaylistListFilter; handleFilterChange(updatedFilters); }, @@ -177,36 +172,30 @@ export const PlaylistListHeaderFilters = ({ ); const handleToggleSortOrder = useCallback(() => { - const newSortOrder = - page.filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; - const updatedFilters = setFilter({ sortOrder: newSortOrder }); + const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; + const updatedFilters = setFilter({ + data: { sortOrder: newSortOrder }, + itemType: LibraryItem.PLAYLIST, + key: pageKey, + }) as PlaylistListFilter; handleFilterChange(updatedFilters); - }, [page.filter.sortOrder, handleFilterChange, setFilter]); + }, [filter.sortOrder, handleFilterChange, setFilter]); const handleSetViewType = useCallback( (e: MouseEvent) => { if (!e.currentTarget?.value) return; - const display = e.currentTarget.value as ListDisplayType; - setPage({ list: { ...page, display: e.currentTarget.value as ListDisplayType } }); - - if (display === ListDisplayType.TABLE) { - tableRef.current?.api.paginationSetPageSize( - tableRef.current.props.infiniteInitialRowCount, - ); - setPagination({ data: { currentPage: 0 } }); - } else if (display === ListDisplayType.TABLE_PAGINATED) { - setPagination({ data: { currentPage: 0 } }); - } + setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); }, - [page, setPage, setPagination, tableRef], + [setDisplayType], ); const handleTableColumns = (values: TableColumn[]) => { - const existingColumns = page.table.columns; + const existingColumns = table.columns; if (values.length === 0) { return setTable({ - columns: [], + data: { columns: [] }, + key: pageKey, }); } @@ -214,18 +203,18 @@ export const PlaylistListHeaderFilters = ({ if (values.length > existingColumns.length) { const newColumn = { column: values[values.length - 1], width: 100 }; - return setTable({ columns: [...existingColumns, newColumn] }); + return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); } // If removing a column const removed = existingColumns.filter((column) => !values.includes(column.column)); const newColumns = existingColumns.filter((column) => !removed.includes(column)); - return setTable({ columns: newColumns }); + return setTable({ data: { columns: newColumns }, key: pageKey }); }; const handleAutoFitColumns = (e: ChangeEvent) => { - setTable({ autoFit: e.currentTarget.checked }); + setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); if (e.currentTarget.checked) { tableRef.current?.api.sizeColumnsToFit(); @@ -234,15 +223,15 @@ export const PlaylistListHeaderFilters = ({ const handleItemSize = (e: number) => { if (isGrid) { - setGrid({ data: { itemsPerRow: e } }); + setGrid({ data: { itemsPerRow: e }, key: pageKey }); } else { - setTable({ rowHeight: e }); + setTable({ data: { rowHeight: e }, key: pageKey }); } }; const handleRefresh = () => { - queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || '', page.filter)); - handleFilterChange(page.filter); + queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || '', filter)); + handleFilterChange(filter); }; return ( @@ -264,21 +253,21 @@ export const PlaylistListHeaderFilters = ({ - {FILTERS[server?.type as keyof typeof FILTERS].map((filter) => ( + {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( - {filter.name} + {f.name} ))} @@ -317,28 +306,28 @@ export const PlaylistListHeaderFilters = ({ Display type Card Poster Table @@ -350,9 +339,7 @@ export const PlaylistListHeaderFilters = ({ column.column, )} width={300} @@ -379,7 +366,7 @@ export const PlaylistListHeaderFilters = ({ Auto Fit Columns diff --git a/src/renderer/features/playlists/components/playlist-list-table-view.tsx b/src/renderer/features/playlists/components/playlist-list-table-view.tsx index 577b6e70..5c8666f0 100644 --- a/src/renderer/features/playlists/components/playlist-list-table-view.tsx +++ b/src/renderer/features/playlists/components/playlist-list-table-view.tsx @@ -1,35 +1,25 @@ -import { - ColDef, - GridReadyEvent, - IDatasource, - PaginationChangedEvent, - BodyScrollEvent, - RowDoubleClickedEvent, -} from '@ag-grid-community/core'; +import { RowDoubleClickedEvent } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { Stack } from '@mantine/core'; -import { useQueryClient } from '@tanstack/react-query'; -import { AnimatePresence } from 'framer-motion'; -import debounce from 'lodash/debounce'; -import { useMemo, useCallback } from 'react'; +import { useCallback } from 'react'; import { generatePath, useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; -import { LibraryItem } from '/@/renderer/api/types'; +import { LibraryItem, PlaylistListQuery, PlaylistListResponse } from '/@/renderer/api/types'; import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; -import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table'; -import { useHandleTableContextMenu } from '/@/renderer/features/context-menu'; +import { VirtualTable } from '/@/renderer/components/virtual-table'; +import { + AgGridFetchFn, + useVirtualTable, +} from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { AppRoute } from '/@/renderer/router/routes'; import { - usePlaylistTablePagination, - useSetPlaylistTablePagination, - useSetPlaylistTable, useCurrentServer, - usePlaylistListStore, useGeneralSettings, + useListStoreActions, + usePlaylistListFilter, + usePlaylistListStore, } from '/@/renderer/store'; -import { ListDisplayType } from '/@/renderer/types'; interface PlaylistListTableViewProps { itemCount?: number; @@ -37,138 +27,38 @@ interface PlaylistListTableViewProps { } export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTableViewProps) => { - const queryClient = useQueryClient(); const navigate = useNavigate(); const server = useCurrentServer(); - const page = usePlaylistListStore(); - const pagination = usePlaylistTablePagination(); - const setPagination = useSetPlaylistTablePagination(); - const setTable = useSetPlaylistTable(); const { defaultFullPlaylist } = useGeneralSettings(); + const { setTable, setTablePagination } = useListStoreActions(); + const pageKey = 'playlist'; + const filter = usePlaylistListFilter({ key: pageKey }); + const listProperties = usePlaylistListStore({ key: pageKey }); - const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED; + console.log('listProperties :>> ', listProperties); - const columnDefs: ColDef[] = useMemo( - () => getColumnDefs(page.table.columns), - [page.table.columns], - ); - - const defaultColumnDefs: ColDef = useMemo(() => { - return { - lockPinned: true, - lockVisible: true, - resizable: true, - }; - }, []); - - const onGridReady = useCallback( - (params: GridReadyEvent) => { - const dataSource: IDatasource = { - getRows: async (params) => { - const limit = params.endRow - params.startRow; - const startIndex = params.startRow; - - const queryKey = queryKeys.playlists.list(server?.id || '', { - limit, - startIndex, - ...page.filter, - }); - - const playlistsRes = await queryClient.fetchQuery( - queryKey, - async ({ signal }) => - api.controller.getPlaylistList({ - apiClientProps: { - server, - signal, - }, - query: { - limit, - startIndex, - ...page.filter, - }, - }), - { cacheTime: 1000 * 60 * 1 }, - ); - - params.successCallback( - playlistsRes?.items || [], - playlistsRes?.totalRecordCount || 0, - ); + const fetchFn: AgGridFetchFn< + PlaylistListResponse, + Omit + > = useCallback( + async ({ filter, limit, startIndex }, signal) => { + const res = api.controller.getPlaylistList({ + apiClientProps: { + server, + signal, }, - rowCount: undefined, - }; - params.api.setDatasource(dataSource); - params.api.ensureIndexVisible(page.table.scrollOffset, 'top'); - }, - [page.filter, page.table.scrollOffset, queryClient, server], - ); - - const onPaginationChanged = useCallback( - (event: PaginationChangedEvent) => { - if (!isPaginationEnabled || !event.api) return; - - try { - // Scroll to top of page on pagination change - const currentPageStartIndex = pagination.currentPage * pagination.itemsPerPage; - event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); - } catch (err) { - console.log(err); - } - - setPagination({ - data: { - itemsPerPage: event.api.paginationGetPageSize(), - totalItems: event.api.paginationGetRowCount(), - totalPages: event.api.paginationGetTotalPages() + 1, + query: { + ...filter, + limit, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + startIndex, }, }); + + return res; }, - [isPaginationEnabled, pagination.currentPage, pagination.itemsPerPage, setPagination], - ); - - const handleGridSizeChange = () => { - if (page.table.autoFit) { - tableRef?.current?.api.sizeColumnsToFit(); - } - }; - - const handleColumnChange = 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.getActualWidth(), - }), - }); - } - } - - setTable({ columns: updatedColumns }); - }, [page.table.autoFit, page.table.columns, setTable, tableRef]); - - const debouncedColumnChange = debounce(handleColumnChange, 200); - - const handleScroll = (e: BodyScrollEvent) => { - const scrollOffset = Number((e.top / page.table.rowHeight).toFixed(0)); - setTable({ scrollOffset }); - }; - - const handleContextMenu = useHandleTableContextMenu( - LibraryItem.PLAYLIST, - PLAYLIST_CONTEXT_MENU_ITEMS, + [server], ); const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { @@ -180,59 +70,34 @@ export const PlaylistListTableView = ({ tableRef, itemCount }: PlaylistListTable } }; + const tableProps = useVirtualTable>( + { + contextMenu: PLAYLIST_CONTEXT_MENU_ITEMS, + fetch: { + filter, + fn: fetchFn, + itemCount, + queryKey: queryKeys.playlists.list, + server, + }, + itemCount, + itemType: LibraryItem.PLAYLIST, + pageKey, + properties: listProperties, + setTable, + setTablePagination, + tableRef, + }, + ); + return ( - - - data.data.id} - infiniteInitialRowCount={itemCount || 100} - pagination={isPaginationEnabled} - paginationAutoPageSize={isPaginationEnabled} - paginationPageSize={page.table.pagination.itemsPerPage || 100} - rowBuffer={20} - rowHeight={page.table.rowHeight || 40} - rowModelType="infinite" - rowSelection="multiple" - onBodyScrollEnd={handleScroll} - onCellContextMenu={handleContextMenu} - onColumnMoved={handleColumnChange} - onColumnResized={debouncedColumnChange} - onGridReady={onGridReady} - onGridSizeChanged={handleGridSizeChange} - onPaginationChanged={onPaginationChanged} - onRowDoubleClicked={handleRowDoubleClick} - /> - - - {page.display === ListDisplayType.TABLE_PAGINATED && ( - - )} - - + + + ); }; diff --git a/src/renderer/store/list.store.ts b/src/renderer/store/list.store.ts index 9ec27a44..a716355a 100644 --- a/src/renderer/store/list.store.ts +++ b/src/renderer/store/list.store.ts @@ -9,6 +9,7 @@ import { AlbumListArgs, AlbumListSort, LibraryItem, + PlaylistListArgs, PlaylistListSort, SongListArgs, SongListSort, @@ -24,10 +25,11 @@ export const generatePageKey = (page: string, id?: string) => { export type AlbumListFilter = Omit; export type SongListFilter = Omit; export type AlbumArtistListFilter = Omit; +export type PlaylistListFilter = Omit; export type ListKey = keyof ListState['item'] | string; -type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter; +type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter | PlaylistListFilter; export type ListTableProps = { pagination: TablePagination; @@ -54,6 +56,7 @@ export interface ListState { album: ListItemProps; albumArtist: ListItemProps; albumDetail: ListItemProps; + playlist: ListItemProps; song: ListItemProps; }; } @@ -399,17 +402,13 @@ export const useListStore = create()( width: 50, }, { - column: TableColumn.TITLE_COMBINED, + column: TableColumn.TITLE, width: 500, }, { - column: TableColumn.DURATION, + column: TableColumn.SONG_COUNT, width: 100, }, - { - column: TableColumn.ALBUM, - width: 500, - }, ], pagination: { currentPage: 1, @@ -422,7 +421,7 @@ export const useListStore = create()( }, }, song: { - display: ListDisplayType.POSTER, + display: ListDisplayType.TABLE, filter: { sortBy: SongListSort.RECENTLY_ADDED, sortOrder: SortOrder.DESC, @@ -540,6 +539,27 @@ export const useSongListStore = (args?: { id?: string; key?: string }) => }; }, shallow); +export const usePlaylistListStore = (args?: { key?: string }) => + useListStore((state) => { + const detail = args?.key ? state.detail[args.key] : undefined; + + return { + ...state.item.playlist, + filter: { + ...state.item.playlist.filter, + ...detail?.filter, + }, + grid: { + ...state.item.playlist.grid, + ...detail?.grid, + }, + table: { + ...state.item.playlist.table, + ...detail?.table, + }, + }; + }, shallow); + export const useSongListFilter = (args: { id?: string; key?: string }) => useListStore((state) => { return state._actions.getFilter({ @@ -567,4 +587,13 @@ export const useAlbumArtistListFilter = (args: { id?: string; key?: string }) => }) as AlbumArtistListFilter; }, shallow); +export const usePlaylistListFilter = (args: { id?: string; key?: string }) => + useListStore((state) => { + return state._actions.getFilter({ + id: args.id, + itemType: LibraryItem.PLAYLIST, + key: args.key, + }) as PlaylistListFilter; + }, shallow); + export const useListDetail = (key: string) => useListStore((state) => state.detail[key], shallow); diff --git a/src/renderer/store/playlist.store.ts b/src/renderer/store/playlist.store.ts index 586af986..129cdb0d 100644 --- a/src/renderer/store/playlist.store.ts +++ b/src/renderer/store/playlist.store.ts @@ -2,8 +2,8 @@ import merge from 'lodash/merge'; import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; -import { PlaylistListArgs, PlaylistListSort, SortOrder } from '/@/renderer/api/types'; -import { SongListFilter } from '/@/renderer/store/list.store'; +import { PlaylistListSort, SortOrder } from '/@/renderer/api/types'; +import { PlaylistListFilter, SongListFilter } from '/@/renderer/store/list.store'; import { DataTableProps } from '/@/renderer/store/settings.store'; import { ListDisplayType, TableColumn, TablePagination } from '/@/renderer/types'; @@ -38,8 +38,6 @@ type ListGridProps = { scrollOffset?: number; }; -export type PlaylistListFilter = Omit; - interface PlaylistState { detail: DetailProps; grid: ListGridProps;