From 4f1d39d3c4ec5d28305ddb5fa4074baaac5967ca Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 13 Nov 2025 21:45:09 -0800 Subject: [PATCH] add new genre list --- src/renderer/api/query-keys.ts | 13 + .../item-table-list/default-columns.ts | 24 +- .../features/genres/api/genres-api.ts | 19 +- .../genres/components/genre-list-content.tsx | 139 +++++-- .../components/genre-list-header-filters.tsx | 356 ++---------------- .../genres/components/genre-list-header.tsx | 68 +--- .../components/genre-list-infinite-grid.tsx | 68 ++++ .../components/genre-list-infinite-table.tsx | 87 +++++ .../components/genre-list-paginated-grid.tsx | 83 ++++ .../components/genre-list-paginated-table.tsx | 98 +++++ .../genres/hooks/use-genre-list-filters.ts | 29 ++ .../genres/routes/genre-list-route.tsx | 42 +-- .../components/list-sort-by-dropdown.tsx | 28 ++ src/renderer/store/settings.store.ts | 57 ++- 14 files changed, 642 insertions(+), 469 deletions(-) create mode 100644 src/renderer/features/genres/components/genre-list-infinite-grid.tsx create mode 100644 src/renderer/features/genres/components/genre-list-infinite-table.tsx create mode 100644 src/renderer/features/genres/components/genre-list-paginated-grid.tsx create mode 100644 src/renderer/features/genres/components/genre-list-paginated-table.tsx create mode 100644 src/renderer/features/genres/hooks/use-genre-list-filters.ts diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index cb657ad21..d779d9059 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -159,6 +159,19 @@ export const queryKeys: Record< root: (serverId: string) => [serverId, 'artists'] as const, }, genres: { + count: (serverId: string, query?: GenreListQuery) => { + const { filter, pagination } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'genres', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'genres', 'count', filter] as const; + } + + return [serverId, 'genres', 'count'] as const; + }, list: (serverId: string, query?: GenreListQuery) => { 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 e73b77b99..878b5f05d 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 @@ -435,27 +435,27 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [ width: 80, }, { - align: 'start', + align: 'center', autoSize: false, isEnabled: true, - label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), - pinned: null, - value: TableColumn.TITLE, - width: 300, + label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }), + pinned: 'left', + value: TableColumn.IMAGE, + width: 70, }, { align: 'start', autoSize: false, - isEnabled: false, - label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }), - pinned: null, - value: TableColumn.TITLE_COMBINED, + isEnabled: true, + label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), + pinned: 'left', + value: TableColumn.TITLE, width: 300, }, { align: 'center', autoSize: false, - isEnabled: true, + isEnabled: false, label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }), pinned: null, value: TableColumn.DURATION, @@ -473,7 +473,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [ { align: 'start', autoSize: false, - isEnabled: true, + isEnabled: false, label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }), pinned: null, value: TableColumn.GENRE, @@ -631,7 +631,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [ }, { align: 'start', - autoSize: false, + autoSize: true, isEnabled: true, label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), pinned: null, diff --git a/src/renderer/features/genres/api/genres-api.ts b/src/renderer/features/genres/api/genres-api.ts index e69be174d..bdaaa66d6 100644 --- a/src/renderer/features/genres/api/genres-api.ts +++ b/src/renderer/features/genres/api/genres-api.ts @@ -3,7 +3,7 @@ import { queryOptions } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; -import { GenreListQuery } from '/@/shared/types/domain-types'; +import { GenreListQuery, ListCountQuery } from '/@/shared/types/domain-types'; export const genresQueries = { list: (args: QueryHookArgs) => { @@ -19,4 +19,21 @@ export const genresQueries = { ...args.options, }); }, + listCount: (args: QueryHookArgs>) => { + return queryOptions({ + queryFn: ({ signal }) => { + return api.controller + .getGenreList({ + apiClientProps: { serverId: args.serverId, signal }, + query: { ...args.query, limit: 1, startIndex: 0 }, + }) + .then((result) => result?.totalRecordCount ?? 0); + }, + queryKey: queryKeys.genres.count( + args.serverId, + Object.keys(args.query).length === 0 ? undefined : args.query, + ), + ...args.options, + }); + }, }; diff --git a/src/renderer/features/genres/components/genre-list-content.tsx b/src/renderer/features/genres/components/genre-list-content.tsx index 9c36f3ad7..7bc7606b9 100644 --- a/src/renderer/features/genres/components/genre-list-content.tsx +++ b/src/renderer/features/genres/components/genre-list-content.tsx @@ -1,42 +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'; +import { useGenreListFilters } from '/@/renderer/features/genres/hooks/use-genre-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 GenreListGridView = lazy(() => - import('/@/renderer/features/genres/components/genre-list-grid-view').then((module) => ({ - default: module.GenreListGridView, +const GenreListInfiniteGrid = lazy(() => + import('/@/renderer/features/genres/components/genre-list-infinite-grid').then((module) => ({ + default: module.GenreListInfiniteGrid, })), ); -const GenreListTableView = lazy(() => - import('/@/renderer/features/genres/components/genre-list-table-view').then((module) => ({ - default: module.GenreListTableView, +const GenreListPaginatedGrid = lazy(() => + import('/@/renderer/features/genres/components/genre-list-paginated-grid').then((module) => ({ + default: module.GenreListPaginatedGrid, })), ); -interface AlbumListContentProps { - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; -} +const GenreListInfiniteTable = lazy(() => + import('/@/renderer/features/genres/components/genre-list-infinite-table').then((module) => ({ + default: module.GenreListInfiniteTable, + })), +); -export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListContentProps) => { - const { pageKey } = useListContext(); - const { display } = useListStoreByKey({ key: pageKey }); +const GenreListPaginatedTable = lazy(() => + import('/@/renderer/features/genres/components/genre-list-paginated-table').then((module) => ({ + default: module.GenreListPaginatedTable, + })), +); + +export const GenreListContent = () => { + const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.GENRE); return ( }> - {display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( - - ) : ( - - )} + ); }; + +export const GenreListView = ({ + display, + grid, + itemsPerPage, + pagination, + table, +}: ItemListSettings) => { + const server = useCurrentServer(); + + const { query } = useGenreListFilters(); + + 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/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index 85dc57687..2795e0ca7 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -1,354 +1,40 @@ -import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; - -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; -import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import i18n from '/@/i18n/i18n'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; -import { useListContext } from '/@/renderer/context/list-context'; -import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; -import { FolderButton } from '/@/renderer/features/shared/components/folder-button'; 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 { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; +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 { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; -import { - GenreListFilter, - GenreTarget, - PersistedTableColumn, - useCurrentServer, - useGeneralSettings, - useListStoreActions, - useListStoreByKey, - useSettingsStoreActions, -} 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 { - GenreListQuery, - GenreListSort, - LibraryItem, - ServerType, - SortOrder, -} from '/@/shared/types/domain-types'; -import { ListDisplayType } from '/@/shared/types/types'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; -const FILTERS = { - jellyfin: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: GenreListSort.NAME, - }, - ], - navidrome: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: GenreListSort.NAME, - }, - ], - subsonic: [ - { - defaultOrder: SortOrder.ASC, - name: i18n.t('filter.name', { postProcess: 'titleCase' }), - value: GenreListSort.NAME, - }, - ], -}; - -interface GenreListHeaderFiltersProps { - gridRef: MutableRefObject; - itemCount: number | undefined; - tableRef: MutableRefObject; -} - -export const GenreListHeaderFilters = ({ - gridRef, - itemCount, - tableRef, -}: GenreListHeaderFiltersProps) => { - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const { customFilters, pageKey } = useListContext(); - const server = useCurrentServer(); - const { setDisplayType, setFilter, setGrid, setTable } = useListStoreActions(); - const { display, filter, grid, table } = useListStoreByKey({ key: pageKey }); +export const GenreListHeaderFilters = () => { const cq = useContainerQuery(); - const { genreTarget } = useGeneralSettings(); - const { setGenreBehavior } = useSettingsStoreActions(); - - const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ - itemCount, - itemType: LibraryItem.GENRE, - server, - }); - - const musicFoldersQuery = useQuery( - sharedQueries.musicFolders({ query: null, serverId: server?.id }), - ); - - const sortByLabel = - (server?.type && - FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy) - ?.name) || - 'Unknown'; - - const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID; - - const onFilterChange = useCallback( - (filter: GenreListFilter) => { - if (isGrid) { - handleRefreshGrid(gridRef, { - ...filter, - ...customFilters, - }); - } else { - handleRefreshTable(tableRef, { - ...filter, - ...customFilters, - }); - } - }, - [customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef], - ); - - const handleRefresh = useCallback(() => { - queryClient.invalidateQueries({ queryKey: queryKeys.genres.list(server?.id || '') }); - onFilterChange(filter); - }, [filter, onFilterChange, queryClient, server?.id]); - - 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({ - customFilters, - data: { - sortBy: e.currentTarget.value as GenreListSort, - sortOrder: sortOrder || SortOrder.ASC, - }, - itemType: LibraryItem.GENRE, - key: pageKey, - }) as GenreListFilter; - - onFilterChange(updatedFilters); - }, - [customFilters, onFilterChange, pageKey, server?.type, setFilter], - ); - - const handleSetMusicFolder = useCallback( - (e: MouseEvent) => { - if (!e.currentTarget?.value) return; - - let updatedFilters: GenreListFilter | null = null; - if (e.currentTarget.value === String(filter.musicFolderId)) { - updatedFilters = setFilter({ - customFilters, - data: { musicFolderId: undefined }, - itemType: LibraryItem.GENRE, - key: pageKey, - }) as GenreListFilter; - } else { - updatedFilters = setFilter({ - customFilters, - data: { musicFolderId: e.currentTarget.value }, - itemType: LibraryItem.GENRE, - key: pageKey, - }) as GenreListFilter; - } - - onFilterChange(updatedFilters); - }, - [filter.musicFolderId, onFilterChange, setFilter, customFilters, pageKey], - ); - - const handleToggleSortOrder = useCallback(() => { - const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; - const updatedFilters = setFilter({ - customFilters, - data: { sortOrder: newSortOrder }, - itemType: LibraryItem.GENRE, - key: pageKey, - }) as GenreListFilter; - onFilterChange(updatedFilters); - }, [customFilters, filter.sortOrder, onFilterChange, pageKey, setFilter]); - - 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 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; - - setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); - } else { - // If removing a column - const removed = existingColumns.filter((column) => !values.includes(column.column)); - const newColumns = existingColumns.filter((column) => !removed.includes(column)); - - setTable({ data: { columns: newColumns }, key: pageKey }); - } - - return tableRef.current?.api.sizeColumnsToFit(); - }; - - const handleAutoFitColumns = (autoFitColumns: boolean) => { - setTable({ data: { autoFit: autoFitColumns }, key: pageKey }); - - if (autoFitColumns) { - tableRef.current?.api.sizeColumnsToFit(); - } - }; - - const isFolderFilterApplied = useMemo(() => { - return filter.musicFolderId !== undefined; - }, [filter.musicFolderId]); - - const handleGenreToggle = useCallback(() => { - const newState = genreTarget === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM; - setGenreBehavior(newState); - }, [genreTarget, setGenreBehavior]); return ( - - - - - - {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( - - {f.name} - - ))} - - + - - {server?.type === ServerType.JELLYFIN && ( - <> - - - - - - - {musicFoldersQuery.data?.items.map((folder) => ( - - {folder.name} - - ))} - - - - )} - - - - - - - } - onClick={handleRefresh} - > - {t('common.refresh', { postProcess: 'titleCase' })} - - - - + + + + column.column)} + listKey={ItemListKey.GENRE} tableColumnsData={GENRE_TABLE_COLUMNS} /> diff --git a/src/renderer/features/genres/components/genre-list-header.tsx b/src/renderer/features/genres/components/genre-list-header.tsx index 1986e8cf9..bf783e994 100644 --- a/src/renderer/features/genres/components/genre-list-header.tsx +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -1,70 +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 { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-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 { GenreListFilter, useCurrentServer } from '/@/renderer/store'; -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 { Stack } from '/@/shared/components/stack/stack'; -import { GenreListQuery, LibraryItem } from '/@/shared/types/domain-types'; interface GenreListHeaderProps { - gridRef: MutableRefObject; - itemCount?: number; - tableRef: MutableRefObject; + title?: string; } -export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeaderProps) => { +export const GenreListHeader = ({ title }: GenreListHeaderProps) => { const { t } = useTranslation(); - const cq = useContainerQuery(); - const server = useCurrentServer(); - const { filter, refresh, search } = useDisplayRefresh({ - gridRef, - itemType: LibraryItem.GENRE, - server, - tableRef, - }); - const handleSearch = debounce((e: ChangeEvent) => { - const updatedFilters = search(e) as GenreListFilter; - refresh(updatedFilters); - }, 500); + const { itemCount } = useListContext(); + const pageTitle = title || t('page.genreList.title', { postProcess: 'titleCase' }); return ( - - - - - - {t('page.genreList.title', { postProcess: 'titleCase' })} - - - {itemCount} - - - - - - + + + + + {pageTitle} + + {itemCount} + + + + + - + ); diff --git a/src/renderer/features/genres/components/genre-list-infinite-grid.tsx b/src/renderer/features/genres/components/genre-list-infinite-grid.tsx new file mode 100644 index 000000000..e76e85026 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-infinite-grid.tsx @@ -0,0 +1,68 @@ +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 { 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 { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { + GenreListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface GenreListInfiniteGridProps extends ItemListGridComponentProps {} + +export const GenreListInfiniteGrid = forwardRef( + ( + { + gap = 'md', + itemsPerPage = 100, + itemsPerRow, + query = { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + }, + ref, + ) => { + const listCountQuery = genresQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getGenreList; + + const { data, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.GENRE, + itemsPerPage, + itemType: LibraryItem.GENRE, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + ); + }, +); diff --git a/src/renderer/features/genres/components/genre-list-infinite-table.tsx b/src/renderer/features/genres/components/genre-list-infinite-table.tsx new file mode 100644 index 000000000..9f4c50fe1 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-infinite-table.tsx @@ -0,0 +1,87 @@ +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 { 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 { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { + GenreListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface GenreListInfiniteTableProps extends ItemListTableComponentProps {} + +export const GenreListInfiniteTable = forwardRef( + ( + { + autoFitColumns = false, + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = genresQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getGenreList; + + const { data, onRangeChanged } = useItemListInfiniteLoader({ + eventKey: ItemListKey.GENRE, + itemsPerPage, + itemType: LibraryItem.GENRE, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + ); + }, +); diff --git a/src/renderer/features/genres/components/genre-list-paginated-grid.tsx b/src/renderer/features/genres/components/genre-list-paginated-grid.tsx new file mode 100644 index 000000000..b334c4edc --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-paginated-grid.tsx @@ -0,0 +1,83 @@ +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 { 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 { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { + GenreListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; + +interface GenreListPaginatedGridProps extends ItemListGridComponentProps {} + +export const GenreListPaginatedGrid = forwardRef( + ( + { + gap = 'md', + itemsPerPage = 100, + itemsPerRow, + query = { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + }, + ref, + ) => { + const listCountQuery = genresQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getGenreList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + itemsPerPage, + itemType: LibraryItem.GENRE, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + + + ); + }, +); diff --git a/src/renderer/features/genres/components/genre-list-paginated-table.tsx b/src/renderer/features/genres/components/genre-list-paginated-table.tsx new file mode 100644 index 000000000..579c16f79 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-paginated-table.tsx @@ -0,0 +1,98 @@ +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 { 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 { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { + GenreListQuery, + GenreListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; + +interface GenreListPaginatedTableProps extends ItemListTableComponentProps {} + +export const GenreListPaginatedTable = forwardRef( + ( + { + autoFitColumns = false, + columns, + enableAlternateRowColors = false, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + itemsPerPage = 100, + query = { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, + size = 'default', + }, + ref, + ) => { + const listCountQuery = genresQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getGenreList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + itemsPerPage, + itemType: LibraryItem.GENRE, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + + + ); + }, +); diff --git a/src/renderer/features/genres/hooks/use-genre-list-filters.ts b/src/renderer/features/genres/hooks/use-genre-list-filters.ts new file mode 100644 index 000000000..e3811a127 --- /dev/null +++ b/src/renderer/features/genres/hooks/use-genre-list-filters.ts @@ -0,0 +1,29 @@ +import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; +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 { FILTER_KEYS } from '/@/renderer/features/shared/utils'; +import { GenreListSort } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +export const useGenreListFilters = () => { + const { sortBy } = useSortByFilter(null, ItemListKey.GENRE); + + const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE); + + const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.GENRE); + + const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + + const query = { + [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, + [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, + [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, + [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, + }; + + return { + query, + setSearchTerm, + }; +}; diff --git a/src/renderer/features/genres/routes/genre-list-route.tsx b/src/renderer/features/genres/routes/genre-list-route.tsx index 5c63b7942..8e9455385 100644 --- a/src/renderer/features/genres/routes/genre-list-route.tsx +++ b/src/renderer/features/genres/routes/genre-list-route.tsx @@ -1,50 +1,30 @@ -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 { ListContext } from '/@/renderer/context/list-context'; -import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; +import { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content'; import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; -import { useCurrentServer } from '/@/renderer/store'; -import { useListStoreByKey } from '/@/renderer/store/list.store'; -import { GenreListQuery } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; const GenreListRoute = () => { - const gridRef = useRef(null); - const tableRef = useRef(null); - const server = useCurrentServer(); - const pageKey = 'genre'; - const { filter } = useListStoreByKey({ key: pageKey }); + const pageKey = ItemListKey.GENRE; - const itemCountCheck = useQuery( - genresQueries.list({ - query: { - ...filter, - limit: 1, - 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: undefined, + itemCount, pageKey, + setItemCount, }; - }, []); + }, [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 0571d5598..34c596ec2 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -6,6 +6,7 @@ import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { AlbumArtistListSort, AlbumListSort, + GenreListSort, LibraryItem, ServerType, SongListSort, @@ -444,8 +445,35 @@ const ALBUM_ARTIST_LIST_FILTERS: Partial< ], }; +const GENRE_LIST_FILTERS: Partial< + Record> +> = { + [ServerType.JELLYFIN]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + ], + [ServerType.NAVIDROME]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + ], + [ServerType.SUBSONIC]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + ], +}; + const FILTERS: Partial> = { [LibraryItem.ALBUM]: ALBUM_LIST_FILTERS, [LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS, + [LibraryItem.GENRE]: GENRE_LIST_FILTERS, [LibraryItem.SONG]: SONG_LIST_FILTERS, }; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 32d04fe21..e3356a6ea 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -8,13 +8,14 @@ import { createWithEqualityFn } from 'zustand/traditional'; import i18n from '/@/i18n/i18n'; import { + ALBUM_ARTIST_TABLE_COLUMNS, ALBUM_TABLE_COLUMNS, + GENRE_TABLE_COLUMNS, pickTableColumns, PLAYLIST_SONG_TABLE_COLUMNS, PLAYLIST_TABLE_COLUMNS, SONG_TABLE_COLUMNS, } from '/@/renderer/components/item-list/item-table-list/default-columns'; -import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table/table-config-dropdown'; import { ContextMenuItemType } from '/@/renderer/features/context-menu/events'; import { AppRoute } from '/@/renderer/router/routes'; import { mergeOverridingColumns } from '/@/renderer/store/utils'; @@ -706,13 +707,13 @@ const initialState: SettingsState = { pagination: ListPaginationType.INFINITE, table: { autoFitColumns: false, - columns: ALBUMARTIST_TABLE_COLUMNS.map((column) => ({ - align: 'start' as const, - autoSize: false, + columns: ALBUM_ARTIST_TABLE_COLUMNS.map((column) => ({ + align: column.align, + autoSize: column.autoSize, id: column.value, - isEnabled: true, - pinned: null, - width: 200, + isEnabled: column.isEnabled, + pinned: column.pinned, + width: column.width, })), enableAlternateRowColors: true, enableHorizontalBorders: true, @@ -732,13 +733,13 @@ const initialState: SettingsState = { pagination: ListPaginationType.INFINITE, table: { autoFitColumns: false, - columns: ALBUMARTIST_TABLE_COLUMNS.map((column) => ({ - align: 'start' as const, - autoSize: false, + columns: ALBUM_ARTIST_TABLE_COLUMNS.map((column) => ({ + align: column.align, + autoSize: column.autoSize, id: column.value, - isEnabled: true, - pinned: null, - width: 200, + isEnabled: column.isEnabled, + pinned: column.pinned, + width: column.width, })), enableAlternateRowColors: true, enableHorizontalBorders: true, @@ -747,6 +748,32 @@ const initialState: SettingsState = { size: 'default', }, }, + [LibraryItem.GENRE]: { + display: ListDisplayType.TABLE, + grid: { + itemGap: 'md', + itemsPerRow: 6, + itemsPerRowEnabled: false, + }, + itemsPerPage: 100, + pagination: ListPaginationType.INFINITE, + table: { + autoFitColumns: false, + columns: GENRE_TABLE_COLUMNS.map((column) => ({ + align: column.align, + autoSize: column.autoSize, + id: column.value, + isEnabled: column.isEnabled, + pinned: column.pinned, + width: column.width, + })), + enableAlternateRowColors: true, + enableHorizontalBorders: true, + enableRowHoverHighlight: true, + enableVerticalBorders: false, + size: 'compact', + }, + }, [LibraryItem.PLAYLIST]: { display: ListDisplayType.TABLE, grid: { @@ -872,8 +899,8 @@ const initialState: SettingsState = { TableColumn.USER_FAVORITE, ], }), - enableAlternateRowColors: true, - enableHorizontalBorders: true, + enableAlternateRowColors: false, + enableHorizontalBorders: false, enableRowHoverHighlight: true, enableVerticalBorders: false, size: 'default',