From 9c2ed36b5ce37f539be8ca1cd678e6a396f192ca Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 14 Nov 2025 18:46:33 -0800 Subject: [PATCH] add new playlist list --- src/renderer/api/query-keys.ts | 13 + .../item-table-list/default-columns.ts | 16 +- .../features/playlists/api/playlists-api.ts | 16 + .../components/playlist-list-content.tsx | 140 ++++-- .../playlist-list-header-filters.tsx | 399 +----------------- .../components/playlist-list-header.tsx | 70 +-- .../playlist-list-infinite-grid.tsx | 75 ++++ .../playlist-list-infinite-table.tsx | 98 +++++ .../playlist-list-paginated-grid.tsx | 86 ++++ .../playlist-list-paginated-table.tsx | 110 +++++ .../hooks/use-playlist-list-filters.ts | 37 ++ .../playlists/routes/playlist-list-route.tsx | 44 +- .../components/list-sort-by-dropdown.tsx | 63 +++ src/renderer/store/settings.store.ts | 6 +- 14 files changed, 671 insertions(+), 502 deletions(-) create mode 100644 src/renderer/features/playlists/components/playlist-list-infinite-grid.tsx create mode 100644 src/renderer/features/playlists/components/playlist-list-infinite-table.tsx create mode 100644 src/renderer/features/playlists/components/playlist-list-paginated-grid.tsx create mode 100644 src/renderer/features/playlists/components/playlist-list-paginated-table.tsx create mode 100644 src/renderer/features/playlists/hooks/use-playlist-list-filters.ts diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 83077ccd1..b028e7e39 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -208,6 +208,19 @@ export const queryKeys: Record< }, }, playlists: { + count: (serverId: string, query?: PlaylistListQuery) => { + const { filter, pagination } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'playlists', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'playlists', 'count', filter] as const; + } + + return [serverId, 'playlists', 'count'] as const; + }, detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => { const { filter, pagination } = splitPaginatedQuery(query); if (query && pagination) { diff --git a/src/renderer/components/item-list/item-table-list/default-columns.ts b/src/renderer/components/item-list/item-table-list/default-columns.ts index b7eb98cee..bd81feb01 100644 --- a/src/renderer/components/item-list/item-table-list/default-columns.ts +++ b/src/renderer/components/item-list/item-table-list/default-columns.ts @@ -431,7 +431,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.ROW_INDEX, width: 80, }, @@ -440,7 +440,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.IMAGE, width: 70, }, @@ -449,7 +449,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.TITLE, width: 300, }, @@ -551,7 +551,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.ROW_INDEX, width: 80, }, @@ -560,7 +560,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.IMAGE, width: 70, }, @@ -569,7 +569,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.TITLE, width: 300, }, @@ -578,7 +578,7 @@ export const PLAYLIST_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: false, label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.TITLE_COMBINED, width: 300, }, @@ -626,7 +626,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [ autoSize: false, isEnabled: true, label: i18n.t('table.config.label.rowIndex', { postProcess: 'titleCase' }), - pinned: 'left', + pinned: null, value: TableColumn.ROW_INDEX, width: 80, }, diff --git a/src/renderer/features/playlists/api/playlists-api.ts b/src/renderer/features/playlists/api/playlists-api.ts index 3f683b707..773026deb 100644 --- a/src/renderer/features/playlists/api/playlists-api.ts +++ b/src/renderer/features/playlists/api/playlists-api.ts @@ -4,6 +4,7 @@ import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { + ListCountQuery, PlaylistDetailQuery, PlaylistListQuery, PlaylistSongListQuery, @@ -35,6 +36,21 @@ export const playlistsQueries = { ...args.options, }); }, + listCount: (args: QueryHookArgs>) => { + return queryOptions({ + queryFn: ({ signal }) => { + return api.controller.getPlaylistListCount({ + apiClientProps: { serverId: args.serverId, signal }, + query: args.query, + }); + }, + queryKey: queryKeys.playlists.count( + args.serverId || '', + Object.keys(args.query).length === 0 ? undefined : args.query, + ), + ...args.options, + }); + }, songList: (args: QueryHookArgs) => { return queryOptions({ queryFn: ({ signal }) => { diff --git a/src/renderer/features/playlists/components/playlist-list-content.tsx b/src/renderer/features/playlists/components/playlist-list-content.tsx index 9fdf24601..ee638021e 100644 --- a/src/renderer/features/playlists/components/playlist-list-content.tsx +++ b/src/renderer/features/playlists/components/playlist-list-content.tsx @@ -1,43 +1,129 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { lazy, Suspense } from 'react'; -import { lazy, MutableRefObject, Suspense } from 'react'; - -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; -import { useListContext } from '/@/renderer/context/list-context'; -import { useListStoreByKey } from '/@/renderer/store/list.store'; +import { usePlaylistListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-list-filters'; +import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; import { Spinner } from '/@/shared/components/spinner/spinner'; -import { ListDisplayType } from '/@/shared/types/types'; +import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types'; -const PlaylistListTableView = lazy(() => - import('/@/renderer/features/playlists/components/playlist-list-table-view').then((module) => ({ - default: module.PlaylistListTableView, +const PlaylistListInfiniteGrid = lazy(() => + import('/@/renderer/features/playlists/components/playlist-list-infinite-grid').then((module) => ({ + default: module.PlaylistListInfiniteGrid, })), ); -const PlaylistListGridView = lazy(() => - import('/@/renderer/features/playlists/components/playlist-list-grid-view').then((module) => ({ - default: module.PlaylistListGridView, +const PlaylistListPaginatedGrid = lazy(() => + import('/@/renderer/features/playlists/components/playlist-list-paginated-grid').then((module) => ({ + default: module.PlaylistListPaginatedGrid, })), ); -interface PlaylistListContentProps { - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; -} +const PlaylistListInfiniteTable = lazy(() => + import('/@/renderer/features/playlists/components/playlist-list-infinite-table').then((module) => ({ + default: module.PlaylistListInfiniteTable, + })), +); -export const PlaylistListContent = ({ gridRef, itemCount, tableRef }: PlaylistListContentProps) => { - const { pageKey } = useListContext(); - const { display } = useListStoreByKey({ key: pageKey }); +const PlaylistListPaginatedTable = lazy(() => + import('/@/renderer/features/playlists/components/playlist-list-paginated-table').then((module) => ({ + default: module.PlaylistListPaginatedTable, + })), +); + +export const PlaylistListContent = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.PLAYLIST); return ( }> - {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( - - ) : ( - - )} -
+ ); }; + +export const PlaylistListView = ({ + display, + grid, + itemsPerPage, + pagination, + table, +}: ItemListSettings) => { + const server = useCurrentServer(); + + const { query } = usePlaylistListFilters(); + + switch (display) { + case ListDisplayType.GRID: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } + case ListDisplayType.TABLE: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } + } + + return null; +}; 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 0099e2606..a3dcf1b53 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -1,357 +1,30 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import { IDatasource } from '@ag-grid-community/core'; import { closeAllModals, openModal } from '@mantine/modals'; -import { useQueryClient } from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import { MouseEvent, MutableRefObject, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import i18n from '/@/i18n/i18n'; -import { api } from '/@/renderer/api'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; -import { useListContext } from '/@/renderer/context/list-context'; import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; -import { MoreButton } from '/@/renderer/features/shared/components/more-button'; -import { OrderToggleButton } from '/@/renderer/features/shared/components/order-toggle-button'; -import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; +import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; +import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; +import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; +import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { useContainerQuery } from '/@/renderer/hooks'; -import { - PersistedTableColumn, - PlaylistListFilter, - useCurrentServer, - useListStoreActions, -} from '/@/renderer/store'; -import { useListStoreByKey } from '/@/renderer/store/list.store'; +import { useCurrentServer } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; -import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; -import { Icon } from '/@/shared/components/icon/icon'; -import { - LibraryItem, - PlaylistListQuery, - PlaylistListSort, - ServerType, - SortOrder, -} from '/@/shared/types/domain-types'; -import { ListDisplayType } from '/@/shared/types/types'; +import { LibraryItem, PlaylistListSort, ServerType, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; -const FILTERS = { - jellyfin: [ - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: PlaylistListSort.DURATION, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: PlaylistListSort.NAME, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), - value: PlaylistListSort.SONG_COUNT, - }, - ], - navidrome: [ - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: PlaylistListSort.DURATION, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: PlaylistListSort.NAME, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.owner', { postProcess: 'titleCase' }), - value: PlaylistListSort.OWNER, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }), - value: PlaylistListSort.PUBLIC, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), - value: PlaylistListSort.SONG_COUNT, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }), - value: PlaylistListSort.UPDATED_AT, - }, - ], - subsonic: [ - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.duration', { postProcess: 'titleCase' }), - value: PlaylistListSort.DURATION, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: PlaylistListSort.NAME, - }, - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.owner', { postProcess: 'titleCase' }), - value: PlaylistListSort.OWNER, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }), - value: PlaylistListSort.PUBLIC, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), - value: PlaylistListSort.SONG_COUNT, - }, - { - defaultOrder: SortOrder.DESC, - name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }), - value: PlaylistListSort.UPDATED_AT, - }, - ], -}; - -interface PlaylistListHeaderFiltersProps { - gridRef: MutableRefObject; - tableRef: MutableRefObject; -} - -export const PlaylistListHeaderFilters = ({ - gridRef, - tableRef, -}: PlaylistListHeaderFiltersProps) => { +export const PlaylistListHeaderFilters = () => { const { t } = useTranslation(); - const { pageKey } = useListContext(); - const queryClient = useQueryClient(); const server = useCurrentServer(); - const { setDisplayType, setFilter, setGrid, setTable, setTablePagination } = - useListStoreActions(); - const { display, filter, grid, table } = useListStoreByKey({ key: pageKey }); const cq = useContainerQuery(); - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; - - const sortByLabel = - (server?.type && - ( - FILTERS[server.type as keyof typeof FILTERS] as { name: string; value: string }[] - ).find((f) => f.value === filter.sortBy)?.name) || - 'Unknown'; - - const fetch = useCallback( - async (skip: number, take: number, filters: PlaylistListFilter) => { - const query: PlaylistListQuery = { - _custom: { - jellyfin: { - ...filters._custom?.jellyfin, - }, - navidrome: { - ...filters._custom?.navidrome, - }, - }, - limit: take, - startIndex: skip, - ...filters, - }; - - const queryKey = queryKeys.playlists.list(server?.id || '', query); - - const playlists = await queryClient.fetchQuery({ - queryFn: async ({ signal }) => - api.controller.getPlaylistList({ - apiClientProps: { - serverId: server?.id || '', - signal, - }, - query, - }), - queryKey, - }); - - return playlists; - }, - [queryClient, server], - ); - - const handleFilterChange = useCallback( - async (filters?: PlaylistListFilter) => { - if (isGrid) { - gridRef.current?.scrollTo(0); - gridRef.current?.resetLoadMoreItemsCache(); - const data = await fetch(0, 200, filters || filter); - if (!data?.items) return; - gridRef.current?.setItemData(data.items); - } else { - const dataSource: IDatasource = { - getRows: async (params) => { - const limit = params.endRow - params.startRow; - const startIndex = params.startRow; - - const pageFilters = filters || filter; - - const queryKey = queryKeys.playlists.list(server?.id || '', { - limit, - startIndex, - ...pageFilters, - }); - - const playlistsRes = await queryClient.fetchQuery({ - queryFn: async ({ signal }) => - api.controller.getPlaylistList({ - apiClientProps: { - serverId: server?.id || '', - signal, - }, - query: { - limit, - startIndex, - ...pageFilters, - }, - }), - queryKey, - }); - - params.successCallback( - playlistsRes?.items || [], - playlistsRes?.totalRecordCount || 0, - ); - }, - rowCount: undefined, - }; - tableRef.current?.api.setDatasource(dataSource); - tableRef.current?.api.purgeInfiniteCache(); - tableRef.current?.api.ensureIndexVisible(0, 'top'); - setTablePagination({ data: { currentPage: 0 }, key: pageKey }); - } - }, - [ - isGrid, - gridRef, - fetch, - filter, - tableRef, - setTablePagination, - pageKey, - server, - queryClient, - ], - ); - - const handleSetSortBy = useCallback( - (e: MouseEvent) => { - if (!e.currentTarget?.value || !server?.type) return; - - const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find( - (f) => f.value === e.currentTarget.value, - )?.defaultOrder; - - const updatedFilters = setFilter({ - data: { - sortBy: e.currentTarget.value as PlaylistListSort, - sortOrder: sortOrder || SortOrder.ASC, - }, - itemType: LibraryItem.PLAYLIST, - key: pageKey, - }) as PlaylistListFilter; - - handleFilterChange(updatedFilters); - }, - [handleFilterChange, pageKey, server?.type, setFilter], - ); - - const handleToggleSortOrder = useCallback(() => { - 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); - }, [filter.sortOrder, handleFilterChange, pageKey, setFilter]); - - const handleSetViewType = useCallback( - (displayType: ListDisplayType) => { - setDisplayType({ data: displayType, key: pageKey }); - }, - [pageKey, setDisplayType], - ); - - const handleTableColumns = (values: string[]) => { - const existingColumns = table.columns; - - if (values.length === 0) { - return setTable({ - data: { columns: [] }, - key: pageKey, - }); - } - - // If adding a column - if (values.length > existingColumns.length) { - const newColumn = { - column: values[values.length - 1], - width: 100, - } as PersistedTableColumn; - - 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({ data: { columns: newColumns }, key: pageKey }); - }; - - const handleAutoFitColumns = (autoFitColumns: boolean) => { - setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - - if (autoFitColumns) { - tableRef.current?.api.sizeColumnsToFit(); - } - }; - - const handleItemSize = (e: number) => { - if (isGrid) { - setGrid({ data: { itemSize: e }, key: pageKey }); - } else { - setTable({ data: { rowHeight: e }, key: pageKey }); - } - }; - - const debouncedHandleItemSize = debounce(handleItemSize, 20); - - const handleItemGap = (e: number) => { - setGrid({ data: { itemGap: e }, key: pageKey }); - }; - - const handleRefresh = () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.playlists.list(server?.id || '', filter), - }); - handleFilterChange(filter); - }; - const handleCreatePlaylistModal = () => { openModal({ children: closeAllModals()} />, - onClose: () => { - tableRef?.current?.api?.purgeInfiniteCache(); - }, size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm', title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }), }); @@ -360,55 +33,25 @@ export const PlaylistListHeaderFilters = ({ return ( - - - - - - {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( - - {f.name} - - ))} - - + - - - - - - - - } - onClick={handleRefresh} - > - {t('common.refresh', { postProcess: 'titleCase' })} - - - + + + - + column.column)} + listKey={ItemListKey.PLAYLIST} tableColumnsData={PLAYLIST_TABLE_COLUMNS} /> diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index ae5fa5721..f60560af0 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -1,72 +1,40 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import debounce from 'lodash/debounce'; -import { ChangeEvent, MutableRefObject } from 'react'; import { useTranslation } from 'react-i18next'; import { PageHeader } from '/@/renderer/components/page-header/page-header'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; +import { useListContext } from '/@/renderer/context/list-context'; import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters'; import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; -import { SearchInput } from '/@/renderer/features/shared/components/search-input'; -import { useContainerQuery } from '/@/renderer/hooks'; -import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh'; -import { PlaylistListFilter, useCurrentServer } from '/@/renderer/store'; -import { Badge } from '/@/shared/components/badge/badge'; -import { Flex } from '/@/shared/components/flex/flex'; +import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input'; import { Group } from '/@/shared/components/group/group'; -import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Stack } from '/@/shared/components/stack/stack'; -import { LibraryItem, PlaylistListQuery } from '/@/shared/types/domain-types'; interface PlaylistListHeaderProps { - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; + title?: string; } -export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistListHeaderProps) => { +export const PlaylistListHeader = ({ title }: PlaylistListHeaderProps) => { const { t } = useTranslation(); - const cq = useContainerQuery(); - const server = useCurrentServer(); - const { filter, refresh, search } = useDisplayRefresh({ - gridRef, - itemCount, - itemType: LibraryItem.PLAYLIST, - server, - tableRef, - }); - - const handleSearch = debounce((e: ChangeEvent) => { - const updatedFilters = search(e) as PlaylistListFilter; - refresh(updatedFilters); - }, 500); + const { itemCount } = useListContext(); + const pageTitle = title || t('page.playlistList.title', { postProcess: 'titleCase' }); return ( - - - - - - {t('page.playlistList.title', { postProcess: 'titleCase' })} - - - {itemCount === null || itemCount === undefined ? ( - - ) : ( - itemCount - )} - - - - - - + + + + + {pageTitle} + + {itemCount} + + + + + - + ); diff --git a/src/renderer/features/playlists/components/playlist-list-infinite-grid.tsx b/src/renderer/features/playlists/components/playlist-list-infinite-grid.tsx new file mode 100644 index 000000000..d27a823cf --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-infinite-grid.tsx @@ -0,0 +1,75 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; +import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { + LibraryItem, + PlaylistListQuery, + PlaylistListSort, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface PlaylistListInfiniteGridProps extends ItemListGridComponentProps {} + +export const PlaylistListInfiniteGrid = forwardRef( + ( + { + gap = 'md', + itemsPerPage = 100, + itemsPerRow, + query = { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + }, + ref, + ) => { + const listCountQuery = playlistsQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getPlaylistList; + + const { data, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.PLAYLIST, + itemsPerPage, + itemType: LibraryItem.PLAYLIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + const rows = useGridRows(LibraryItem.PLAYLIST, ItemListKey.PLAYLIST); + + return ( + + ); + }, +); diff --git a/src/renderer/features/playlists/components/playlist-list-infinite-table.tsx b/src/renderer/features/playlists/components/playlist-list-infinite-table.tsx new file mode 100644 index 000000000..e1b72d686 --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-infinite-table.tsx @@ -0,0 +1,98 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { + LibraryItem, + PlaylistListQuery, + PlaylistListSort, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface PlaylistListInfiniteTableProps extends ItemListTableComponentProps {} + +export const PlaylistListInfiniteTable = forwardRef( + ( + { + autoFitColumns = false, + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = playlistsQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getPlaylistList; + + const { data, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.PLAYLIST, + itemsPerPage, + itemType: LibraryItem.PLAYLIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.PLAYLIST, + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.PLAYLIST, + }); + + return ( + + ); + }, +); diff --git a/src/renderer/features/playlists/components/playlist-list-paginated-grid.tsx b/src/renderer/features/playlists/components/playlist-list-paginated-grid.tsx new file mode 100644 index 000000000..c6b7a9f8a --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-paginated-grid.tsx @@ -0,0 +1,86 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { + LibraryItem, + PlaylistListQuery, + PlaylistListSort, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface PlaylistListPaginatedGridProps extends ItemListGridComponentProps {} + +export const PlaylistListPaginatedGrid = forwardRef( + ( + { + gap = 'md', + itemsPerPage = 100, + query = { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + }, + ref, + ) => { + const listCountQuery = playlistsQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getPlaylistList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + itemsPerPage, + itemType: LibraryItem.PLAYLIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + const rows = useGridRows(LibraryItem.PLAYLIST, ItemListKey.PLAYLIST); + + return ( + + + + ); + }, +); diff --git a/src/renderer/features/playlists/components/playlist-list-paginated-table.tsx b/src/renderer/features/playlists/components/playlist-list-paginated-table.tsx new file mode 100644 index 000000000..f8bbc1e5f --- /dev/null +++ b/src/renderer/features/playlists/components/playlist-list-paginated-table.tsx @@ -0,0 +1,110 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; +import { forwardRef } from 'react'; + +import { api } from '/@/renderer/api'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; +import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; +import { + LibraryItem, + PlaylistListQuery, + PlaylistListSort, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface PlaylistListPaginatedTableProps extends ItemListTableComponentProps {} + +export const PlaylistListPaginatedTable = forwardRef( + ( + { + autoFitColumns = false, + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = playlistsQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getPlaylistList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + itemsPerPage, + itemType: LibraryItem.PLAYLIST, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.PLAYLIST, + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.PLAYLIST, + }); + + return ( + + + + ); + }, +); diff --git a/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts b/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts new file mode 100644 index 000000000..bf2c28cd5 --- /dev/null +++ b/src/renderer/features/playlists/hooks/use-playlist-list-filters.ts @@ -0,0 +1,37 @@ +import { + parseAsJson, + parseAsString, + useQueryState, +} from 'nuqs'; + +import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; +import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; +import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; +import { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { PlaylistListSort } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +export const usePlaylistListFilters = () => { + const sortByFilter = useSortByFilter(null, ItemListKey.PLAYLIST); + const sortOrderFilter = useSortOrderFilter(null, ItemListKey.PLAYLIST); + + const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + + const [custom, setCustom] = useQueryState( + 'playlistCustom', + parseAsJson(customFiltersSchema), + ); + + const query = { + searchTerm: searchTerm ?? undefined, + sortBy: sortByFilter[FILTER_KEYS.SHARED.SORT_BY] ?? undefined, + sortOrder: sortOrderFilter[FILTER_KEYS.SHARED.SORT_ORDER] ?? undefined, + _custom: custom ?? undefined, + }; + + return { + query, + setCustom, + setSearchTerm, + }; +}; diff --git a/src/renderer/features/playlists/routes/playlist-list-route.tsx b/src/renderer/features/playlists/routes/playlist-list-route.tsx index 7a02b414f..9c6bdeb71 100644 --- a/src/renderer/features/playlists/routes/playlist-list-route.tsx +++ b/src/renderer/features/playlists/routes/playlist-list-route.tsx @@ -1,58 +1,32 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import { useQuery } from '@tanstack/react-query'; -import { useMemo, useRef } from 'react'; +import { useMemo, useState } from 'react'; import { useParams } from 'react-router'; import { ListContext } from '/@/renderer/context/list-context'; -import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { PlaylistListContent } from '/@/renderer/features/playlists/components/playlist-list-content'; import { PlaylistListHeader } from '/@/renderer/features/playlists/components/playlist-list-header'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; -import { useCurrentServer, useListStoreByKey } from '/@/renderer/store'; -import { PlaylistListSort, PlaylistSongListQuery, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; const PlaylistListRoute = () => { - const gridRef = useRef(null); - const tableRef = useRef(null); - const server = useCurrentServer(); const { playlistId } = useParams(); - const pageKey = 'playlist'; - const { filter } = useListStoreByKey({ key: pageKey }); + const pageKey = ItemListKey.PLAYLIST; - const itemCountCheck = useQuery( - playlistsQueries.list({ - options: { - gcTime: 1000 * 60 * 60 * 2, - }, - query: { - ...filter, - limit: 1, - sortBy: PlaylistListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, - serverId: server?.id, - }), - ); - - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const [itemCount, setItemCount] = useState(undefined); const providerValue = useMemo(() => { return { id: playlistId, + itemCount, pageKey, + setItemCount, }; - }, [playlistId]); + }, [playlistId, itemCount, pageKey, setItemCount]); return ( - - {/* */} + + ); diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index 820447ad4..a672a56cc 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -9,6 +9,7 @@ import { ArtistListSort, GenreListSort, LibraryItem, + PlaylistListSort, ServerType, SongListSort, SortOrder, @@ -558,10 +559,72 @@ const GENRE_LIST_FILTERS: Partial< ], }; +const PLAYLIST_LIST_FILTERS: Partial< + Record> +> = { + [ServerType.JELLYFIN]: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: PlaylistListSort.DURATION, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: PlaylistListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: PlaylistListSort.SONG_COUNT, + }, + ], + [ServerType.NAVIDROME]: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: PlaylistListSort.DURATION, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: PlaylistListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.owner', { postProcess: 'titleCase' }), + value: PlaylistListSort.OWNER, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }), + value: PlaylistListSort.PUBLIC, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: PlaylistListSort.SONG_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }), + value: PlaylistListSort.UPDATED_AT, + }, + ], + [ServerType.SUBSONIC]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: PlaylistListSort.NAME, + }, + ], +}; + const FILTERS: Partial> = { [LibraryItem.ALBUM]: ALBUM_LIST_FILTERS, [LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS, [LibraryItem.ARTIST]: ARTIST_LIST_FILTERS, [LibraryItem.GENRE]: GENRE_LIST_FILTERS, + [LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS, [LibraryItem.SONG]: SONG_LIST_FILTERS, }; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index e7ebaab31..5ef5a7439 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -790,7 +790,7 @@ const initialState: SettingsState = { itemsPerPage: 100, pagination: ListPaginationType.INFINITE, table: { - autoFitColumns: false, + autoFitColumns: true, columns: ALBUM_ARTIST_TABLE_COLUMNS.map((column) => ({ align: column.align, autoSize: column.autoSize, @@ -834,7 +834,7 @@ const initialState: SettingsState = { itemsPerPage: 100, pagination: ListPaginationType.INFINITE, table: { - autoFitColumns: false, + autoFitColumns: true, columns: GENRE_TABLE_COLUMNS.map((column) => ({ align: column.align, autoSize: column.autoSize, @@ -866,7 +866,7 @@ const initialState: SettingsState = { itemsPerPage: 100, pagination: ListPaginationType.INFINITE, table: { - autoFitColumns: false, + autoFitColumns: true, columns: PLAYLIST_TABLE_COLUMNS.map((column) => ({ align: column.align, autoSize: column.autoSize,