From 8029712b55e70c88a2a4163eb3bd390d93c7c98e Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 31 Jul 2023 17:16:48 -0700 Subject: [PATCH] Add initial genre list support --- src/renderer/api/jellyfin.types.ts | 4 + src/renderer/api/jellyfin/jellyfin-api.ts | 1 + .../api/jellyfin/jellyfin-controller.ts | 16 +- src/renderer/api/jellyfin/jellyfin-types.ts | 35 +- src/renderer/api/navidrome/navidrome-api.ts | 1 + .../api/navidrome/navidrome-controller.ts | 15 +- src/renderer/api/navidrome/navidrome-types.ts | 12 + src/renderer/api/query-keys.ts | 14 +- src/renderer/api/types.ts | 42 ++- .../virtual-table/hooks/use-virtual-table.ts | 38 ++- .../virtual-table/table-config-dropdown.tsx | 6 + .../components/jellyfin-album-filters.tsx | 11 +- .../components/navidrome-album-filters.tsx | 11 +- .../genres/components/genre-list-content.tsx | 46 +++ .../components/genre-list-grid-view.tsx | 116 +++++++ .../components/genre-list-header-filters.tsx | 305 ++++++++++++++++++ .../genres/components/genre-list-header.tsx | 93 ++++++ .../components/genre-list-table-view.tsx | 41 +++ .../genres/queries/genre-list-query.ts | 4 +- .../genres/routes/genre-list-route.tsx | 96 ++++++ .../components/jellyfin-song-filters.tsx | 11 +- .../components/navidrome-song-filters.tsx | 11 +- src/renderer/hooks/use-list-filter-refresh.ts | 29 +- src/renderer/router/app-router.tsx | 7 + src/renderer/store/list.store.ts | 44 ++- 25 files changed, 968 insertions(+), 41 deletions(-) create mode 100644 src/renderer/features/genres/components/genre-list-content.tsx create mode 100644 src/renderer/features/genres/components/genre-list-grid-view.tsx create mode 100644 src/renderer/features/genres/components/genre-list-header-filters.tsx create mode 100644 src/renderer/features/genres/components/genre-list-header.tsx create mode 100644 src/renderer/features/genres/components/genre-list-table-view.tsx create mode 100644 src/renderer/features/genres/routes/genre-list-route.tsx diff --git a/src/renderer/api/jellyfin.types.ts b/src/renderer/api/jellyfin.types.ts index c1a57fac..7a6be466 100644 --- a/src/renderer/api/jellyfin.types.ts +++ b/src/renderer/api/jellyfin.types.ts @@ -15,6 +15,10 @@ export interface JFGenreListResponse extends JFBasePaginatedResponse { export type JFGenreList = JFGenreListResponse; +export enum JFGenreListSort { + NAME = 'Name,SortName', +} + export type JFAlbumArtistDetailResponse = JFAlbumArtist; export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse; diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 74b8857c..4710d62f 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -108,6 +108,7 @@ export const contract = c.router({ getGenreList: { method: 'GET', path: 'genres', + query: jfType._parameters.genreList, responses: { 200: jfType._response.genreList, 400: jfType._response.error, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 72be001e..70d80a07 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -46,6 +46,7 @@ import { RandomSongListArgs, LyricsArgs, LyricsResponse, + genreListSortMap, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -116,9 +117,16 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise => { - const { apiClientProps } = args; + const { apiClientProps, query } = args; - const res = await jfApiClient(apiClientProps).getGenreList(); + const res = await jfApiClient(apiClientProps).getGenreList({ + query: { + SearchTerm: query?.searchTerm, + SortBy: genreListSortMap.jellyfin[query.sortBy] || 'Name,SortName', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + }, + }); if (res.status !== 200) { throw new Error('Failed to get genre list'); @@ -126,8 +134,8 @@ const getGenreList = async (args: GenreListArgs): Promise => return { items: res.body.Items.map(jfNormalize.genre), - startIndex: 0, - totalRecordCount: res.body?.Items?.length || 0, + startIndex: query.startIndex || 0, + totalRecordCount: res.body?.TotalRecordCount || 0, }; }; diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 4bc8b009..4314baab 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -304,10 +304,21 @@ const genre = z.object({ Type: z.string(), }); -const genreList = z.object({ +const genreList = pagination.extend({ Items: z.array(genre), }); +const genreListSort = { + NAME: 'Name,SortName', +} as const; + +const genreListParameters = paginationParameters.merge( + baseParameters.extend({ + SearchTerm: z.string().optional(), + SortBy: z.nativeEnum(genreListSort).optional(), + }), +); + const musicFolder = z.object({ BackdropImageTags: z.array(z.string()), ChannelId: z.null(), @@ -352,7 +363,7 @@ const playlist = z.object({ UserData: userData, }); -const jfPlaylistListSort = { +const playlistListSort = { ALBUM_ARTIST: 'AlbumArtist,SortName', DURATION: 'Runtime', NAME: 'SortName', @@ -363,7 +374,7 @@ const jfPlaylistListSort = { const playlistListParameters = paginationParameters.merge( baseParameters.extend({ IncludeItemTypes: z.literal('Playlist'), - SortBy: z.nativeEnum(jfPlaylistListSort).optional(), + SortBy: z.nativeEnum(playlistListSort).optional(), }), ); @@ -461,7 +472,7 @@ const album = z.object({ UserData: userData.optional(), }); -const jfAlbumListSort = { +const albumListSort = { ALBUM_ARTIST: 'AlbumArtist,SortName', COMMUNITY_RATING: 'CommunityRating,SortName', CRITIC_RATING: 'CriticRating,SortName', @@ -479,7 +490,7 @@ const albumListParameters = paginationParameters.merge( IncludeItemTypes: z.literal('MusicAlbum'), IsFavorite: z.boolean().optional(), SearchTerm: z.string().optional(), - SortBy: z.nativeEnum(jfAlbumListSort).optional(), + SortBy: z.nativeEnum(albumListSort).optional(), Tags: z.string().optional(), Years: z.string().optional(), }), @@ -489,7 +500,7 @@ const albumList = pagination.extend({ Items: z.array(album), }); -const jfAlbumArtistListSort = { +const albumArtistListSort = { ALBUM: 'Album,SortName', DURATION: 'Runtime,AlbumArtist,Album,SortName', NAME: 'Name,SortName', @@ -502,7 +513,7 @@ const albumArtistListParameters = paginationParameters.merge( baseParameters.extend({ Filters: z.string().optional(), Genres: z.string().optional(), - SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(), + SortBy: z.nativeEnum(albumArtistListSort).optional(), Years: z.string().optional(), }), ); @@ -515,7 +526,7 @@ const similarArtistListParameters = baseParameters.extend({ Limit: z.number().optional(), }); -const jfSongListSort = { +const songListSort = { ALBUM: 'Album,SortName', ALBUM_ARTIST: 'AlbumArtist,Album,SortName', ARTIST: 'Artist,Album,SortName', @@ -539,7 +550,7 @@ const songListParameters = paginationParameters.merge( Genres: z.string().optional(), IsFavorite: z.boolean().optional(), SearchTerm: z.string().optional(), - SortBy: z.nativeEnum(jfSongListSort).optional(), + SortBy: z.nativeEnum(songListSort).optional(), Tags: z.string().optional(), Years: z.string().optional(), }), @@ -642,9 +653,14 @@ const lyrics = z.object({ export const jfType = { _enum: { + albumArtistList: albumArtistListSort, + albumList: albumListSort, collection: jfCollection, external: jfExternal, + genreList: genreListSort, image: jfImage, + playlistList: playlistListSort, + songList: songListSort, }, _parameters: { addToPlaylist: addToPlaylistParameters, @@ -656,6 +672,7 @@ export const jfType = { createPlaylist: createPlaylistParameters, deletePlaylist: deletePlaylistParameters, favorite: favoriteParameters, + genreList: genreListParameters, musicFolderList: musicFolderListParameters, playlistDetail: playlistDetailParameters, playlistList: playlistListParameters, diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index da23eb9e..32852e93 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -88,6 +88,7 @@ export const contract = c.router({ getGenreList: { method: 'GET', path: 'genre', + query: ndType._parameters.genreList, responses: { 200: resultWithHeaders(ndType._response.genreList), 500: resultWithHeaders(ndType._response.error), diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 04332b9b..da120dcd 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -38,6 +38,7 @@ import { PlaylistSongListResponse, RemoveFromPlaylistResponse, RemoveFromPlaylistArgs, + genreListSortMap, } from '../types'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; @@ -94,9 +95,17 @@ const getUserList = async (args: UserListArgs): Promise => { }; const getGenreList = async (args: GenreListArgs): Promise => { - const { apiClientProps } = args; + const { query, apiClientProps } = args; - const res = await ndApiClient(apiClientProps).getGenreList({}); + const res = await ndApiClient(apiClientProps).getGenreList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: genreListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + name: query.searchTerm, + }, + }); if (res.status !== 200) { throw new Error('Failed to get genre list'); @@ -104,7 +113,7 @@ const getGenreList = async (args: GenreListArgs): Promise => return { items: res.body.data, - startIndex: 0, + startIndex: query.startIndex || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }; diff --git a/src/renderer/api/navidrome/navidrome-types.ts b/src/renderer/api/navidrome/navidrome-types.ts index dd679438..498fbb7c 100644 --- a/src/renderer/api/navidrome/navidrome-types.ts +++ b/src/renderer/api/navidrome/navidrome-types.ts @@ -52,6 +52,16 @@ const genre = z.object({ name: z.string(), }); +const genreListSort = { + NAME: 'name', + SONG_COUNT: 'songCount', +} as const; + +const genreListParameters = paginationParameters.extend({ + _sort: z.nativeEnum(genreListSort).optional(), + name: z.string().optional(), +}); + const genreList = z.array(genre); const albumArtist = z.object({ @@ -322,6 +332,7 @@ export const ndType = { _enum: { albumArtistList: ndAlbumArtistListSort, albumList: ndAlbumListSort, + genreList: genreListSort, playlistList: ndPlaylistListSort, songList: ndSongListSort, userList: ndUserListSort, @@ -332,6 +343,7 @@ export const ndType = { albumList: albumListParameters, authenticate: authenticateParameters, createPlaylist: createPlaylistParameters, + genreList: genreListParameters, playlistList: playlistListParameters, removeFromPlaylist: removeFromPlaylistParameters, songList: songListParameters, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 8dc9e767..557ec210 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -17,6 +17,7 @@ import type { RandomSongListQuery, LyricsQuery, LyricSearchQuery, + GenreListQuery, } from './types'; export const splitPaginatedQuery = (key: any) => { @@ -106,7 +107,18 @@ export const queryKeys: Record< root: (serverId: string) => [serverId, 'artists'] as const, }, genres: { - list: (serverId: string) => [serverId, 'genres', 'list'] as const, + list: (serverId: string, query?: GenreListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + if (query && pagination) { + return [serverId, 'genres', 'list', filter, pagination] as const; + } + + if (query) { + return [serverId, 'genres', 'list', filter] as const; + } + + return [serverId, 'genres', 'list'] as const; + }, root: (serverId: string) => [serverId, 'genres'] as const, }, musicFolders: { diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index d8f1ef8f..a3640ba2 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { jfType } from './jellyfin/jellyfin-types'; import { JFSortOrder, JFAlbumListSort, @@ -6,8 +7,9 @@ import { JFAlbumArtistListSort, JFArtistListSort, JFPlaylistListSort, + JFGenreListSort, } from './jellyfin.types'; -import { jfType } from './jellyfin/jellyfin-types'; +import { ndType } from './navidrome/navidrome-types'; import { NDSortOrder, NDOrder, @@ -16,13 +18,14 @@ import { NDPlaylistListSort, NDSongListSort, NDUserListSort, + NDGenreListSort, } from './navidrome.types'; -import { ndType } from './navidrome/navidrome-types'; export enum LibraryItem { ALBUM = 'album', ALBUM_ARTIST = 'albumArtist', ARTIST = 'artist', + GENRE = 'genre', PLAYLIST = 'playlist', SONG = 'song', } @@ -292,7 +295,40 @@ export type GenreListResponse = BasePaginatedResponse | null | undefine export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; -export type GenreListQuery = null; +export enum GenreListSort { + NAME = 'name', +} + +export type GenreListQuery = { + _custom?: { + jellyfin?: null; + navidrome?: null; + }; + limit?: number; + musicFolderId?: string; + searchTerm?: string; + sortBy: GenreListSort; + sortOrder: SortOrder; + startIndex: number; +}; + +type GenreListSortMap = { + jellyfin: Record; + navidrome: Record; + subsonic: Record; +}; + +export const genreListSortMap: GenreListSortMap = { + jellyfin: { + name: JFGenreListSort.NAME, + }, + navidrome: { + name: NDGenreListSort.NAME, + }, + subsonic: { + name: undefined, + }, +}; // Album List export type AlbumListResponse = BasePaginatedResponse | null | undefined; diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index 9ced8c58..f9bb806f 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -12,6 +12,7 @@ import { import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { QueryKey, useQueryClient } from '@tanstack/react-query'; import debounce from 'lodash/debounce'; +import orderBy from 'lodash/orderBy'; import { generatePath, useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys'; @@ -32,6 +33,7 @@ export type AgGridFetchFn = ( interface UseAgGridProps { contextMenu: SetContextMenuItems; customFilters?: Partial; + isClientSideSort?: boolean; isSearchParams?: boolean; itemCount?: number; itemType: LibraryItem; @@ -49,6 +51,7 @@ export const useVirtualTable = ({ itemCount, customFilters, isSearchParams, + isClientSideSort, }: UseAgGridProps) => { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -104,6 +107,9 @@ export const useVirtualTable = ({ if (itemType === LibraryItem.SONG) { return queryKeys.songs.list; } + if (itemType === LibraryItem.GENRE) { + return queryKeys.genres.list; + } return null; }, [itemType]); @@ -122,6 +128,9 @@ export const useVirtualTable = ({ if (itemType === LibraryItem.SONG) { return api.controller.getSongList; } + if (itemType === LibraryItem.GENRE) { + return api.controller.getGenreList; + } return null; }, [itemType]); @@ -160,6 +169,17 @@ export const useVirtualTable = ({ return res; })) as BasePaginatedResponse; + if (isClientSideSort && results?.items) { + const sortedResults = orderBy( + results.items, + [(item) => String(item[properties.filter.sortBy]).toLowerCase()], + properties.filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], + ); + + params.successCallback(sortedResults || [], results?.totalRecordCount || 0); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -168,7 +188,15 @@ export const useVirtualTable = ({ params.api.setDatasource(dataSource); params.api.ensureIndexVisible(initialTableIndex, 'top'); }, - [initialTableIndex, queryKeyFn, server, properties.filter, queryClient, queryFn], + [ + initialTableIndex, + queryKeyFn, + server, + properties.filter, + queryClient, + isClientSideSort, + queryFn, + ], ); const setParamsTablePagination = useCallback( @@ -206,10 +234,10 @@ export const useVirtualTable = ({ if (isSearchParams) { setSearchParams( (params) => { - params.set('currentPage', String(event.api?.paginationGetCurrentPage())); - params.set('itemsPerPage', String(event.api?.paginationGetPageSize())); - params.set('totalItems', String(event.api?.paginationGetRowCount())); - params.set('totalPages', String(event.api?.paginationGetTotalPages() + 1)); + params.set('currentPage', String(event.api.paginationGetCurrentPage())); + params.set('itemsPerPage', String(event.api.paginationGetPageSize())); + params.set('totalItems', String(event.api.paginationGetRowCount())); + params.set('totalPages', String(event.api.paginationGetTotalPages() + 1)); return params; }, { replace: true }, diff --git a/src/renderer/components/virtual-table/table-config-dropdown.tsx b/src/renderer/components/virtual-table/table-config-dropdown.tsx index c7464852..3b46d9de 100644 --- a/src/renderer/components/virtual-table/table-config-dropdown.tsx +++ b/src/renderer/components/virtual-table/table-config-dropdown.tsx @@ -84,6 +84,12 @@ export const PLAYLIST_TABLE_COLUMNS = [ { label: 'Actions', value: TableColumn.ACTIONS }, ]; +export const GENRE_TABLE_COLUMNS = [ + { label: 'Row Index', value: TableColumn.ROW_INDEX }, + { label: 'Title', value: TableColumn.TITLE }, + { label: 'Actions', value: TableColumn.ACTIONS }, +]; + interface TableConfigDropdownProps { type: TableType; } diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 89330c11..711bffb7 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -2,7 +2,7 @@ import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo, useState } from 'react'; import { useListFilterByKey } from '../../../store/list.store'; -import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; import { useGenreList } from '/@/renderer/features/genres'; @@ -27,7 +27,14 @@ export const JellyfinAlbumFilters = ({ const { setFilter } = useListStoreActions(); // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library - const genreListQuery = useGenreList({ query: null, serverId }); + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 9cdacaa1..c693d125 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -5,7 +5,7 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend import debounce from 'lodash/debounce'; import { useGenreList } from '/@/renderer/features/genres'; import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; -import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; interface NavidromeAlbumFiltersProps { customFilters?: Partial; @@ -25,7 +25,14 @@ export const NavidromeAlbumFilters = ({ const { filter } = useListStoreByKey({ key: pageKey }); const { setFilter } = useListStoreActions(); - const genreListQuery = useGenreList({ query: null, serverId }); + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; diff --git a/src/renderer/features/genres/components/genre-list-content.tsx b/src/renderer/features/genres/components/genre-list-content.tsx new file mode 100644 index 00000000..0d0cc092 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-content.tsx @@ -0,0 +1,46 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { lazy, MutableRefObject, Suspense } from 'react'; +import { Spinner } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { useListContext } from '/@/renderer/context/list-context'; +import { useListStoreByKey } from '/@/renderer/store'; +import { ListDisplayType } from '/@/renderer/types'; + +const GenreListGridView = lazy(() => + import('/@/renderer/features/genres/components/genre-list-grid-view').then((module) => ({ + default: module.GenreListGridView, + })), +); + +const GenreListTableView = lazy(() => + import('/@/renderer/features/genres/components/genre-list-table-view').then((module) => ({ + default: module.GenreListTableView, + })), +); + +interface AlbumListContentProps { + gridRef: MutableRefObject; + itemCount?: number; + tableRef: MutableRefObject; +} + +export const GenreListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => { + const { pageKey } = useListContext(); + const { display } = useListStoreByKey({ key: pageKey }); + + return ( + }> + {display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/renderer/features/genres/components/genre-list-grid-view.tsx b/src/renderer/features/genres/components/genre-list-grid-view.tsx new file mode 100644 index 00000000..4a70aac4 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-grid-view.tsx @@ -0,0 +1,116 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; +import { ListOnScrollProps } from 'react-window'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { Album, GenreListQuery, LibraryItem } from '/@/renderer/api/types'; +import { ALBUM_CARD_ROWS } from '/@/renderer/components'; +import { + VirtualGridAutoSizerContainer, + VirtualInfiniteGrid, +} from '/@/renderer/components/virtual-grid'; +import { useListContext } from '/@/renderer/context/list-context'; +import { usePlayQueueAdd } from '/@/renderer/features/player'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useCurrentServer, useListStoreActions, useListStoreByKey } from '/@/renderer/store'; +import { CardRow, ListDisplayType } from '/@/renderer/types'; + +export const GenreListGridView = ({ gridRef, itemCount }: any) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const handlePlayQueueAdd = usePlayQueueAdd(); + const { pageKey, id } = useListContext(); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { setGrid } = useListStoreActions(); + + const [searchParams, setSearchParams] = useSearchParams(); + const scrollOffset = searchParams.get('scrollOffset'); + const initialScrollOffset = Number(id ? scrollOffset : grid?.scrollOffset) || 0; + + const cardRows = useMemo(() => { + const rows: CardRow[] = [ALBUM_CARD_ROWS.name]; + return rows; + }, []); + + const handleGridScroll = useCallback( + (e: ListOnScrollProps) => { + if (id) { + setSearchParams( + (params) => { + params.set('scrollOffset', String(e.scrollOffset)); + return params; + }, + { replace: true }, + ); + } else { + setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey }); + } + }, + [id, pageKey, setGrid, setSearchParams], + ); + + const fetch = useCallback( + async ({ skip, take }: { skip: number; take: number }) => { + if (!server) { + return []; + } + + const query: GenreListQuery = { + ...filter, + limit: take, + startIndex: skip, + }; + + const queryKey = queryKeys.albums.list(server?.id || '', query); + + const albums = await queryClient.fetchQuery({ + queryFn: async ({ signal }) => { + return api.controller.getGenreList({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey, + }); + + return albums; + }, + [filter, queryClient, server], + ); + + return ( + + + {({ height, width }: Size) => ( + + )} + + + ); +}; diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx new file mode 100644 index 00000000..1458ed05 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -0,0 +1,305 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +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 { queryKeys } from '/@/renderer/api/query-keys'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; +import { useListContext } from '/@/renderer/context/list-context'; +import { OrderToggleButton } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; +import { + GenreListFilter, + useCurrentServer, + useListStoreActions, + useListStoreByKey, +} from '/@/renderer/store'; +import { ListDisplayType, TableColumn } from '/@/renderer/types'; + +const FILTERS = { + jellyfin: [{ defaultOrder: SortOrder.ASC, name: 'Name', value: GenreListSort.NAME }], + navidrome: [{ defaultOrder: SortOrder.ASC, name: 'Name', value: GenreListSort.NAME }], +}; + +interface GenreListHeaderFiltersProps { + gridRef: MutableRefObject; + tableRef: MutableRefObject; +} + +export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { + const queryClient = useQueryClient(); + const { pageKey, customFilters } = useListContext(); + const server = useCurrentServer(); + const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions(); + const { display, filter, table, grid } = useListStoreByKey({ key: pageKey }); + const cq = useContainerQuery(); + + const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemType: LibraryItem.GENRE, + server, + }); + + 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.POSTER; + + 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(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 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: { itemsPerRow: e }, key: pageKey }); + } else { + setTable({ data: { rowHeight: e }, key: pageKey }); + } + }; + + const handleSetViewType = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + }, + [pageKey, setDisplayType], + ); + + const handleTableColumns = (values: TableColumn[]) => { + 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 }; + + 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 = (e: ChangeEvent) => { + setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + + return ( + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( + + {f.name} + + ))} + + + + + + + + + + + } + onClick={handleRefresh} + > + Refresh + + + + + + + + + + + Display type + + Card + + + Poster + + + Table + + + + {isGrid ? 'Items per row' : 'Item size'} + + + + + {(display === ListDisplayType.TABLE || + display === ListDisplayType.TABLE_PAGINATED) && ( + <> + Table Columns + + + column.column, + )} + width={300} + onChange={handleTableColumns} + /> + + Auto Fit Columns + + + + + + )} + + + + + ); +}; diff --git a/src/renderer/features/genres/components/genre-list-header.tsx b/src/renderer/features/genres/components/genre-list-header.tsx new file mode 100644 index 00000000..e37ad9f8 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -0,0 +1,93 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Flex, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, MutableRefObject } from 'react'; +import { LibraryItem } from '/@/renderer/api/types'; +import { PageHeader, SearchInput } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { useListContext } from '/@/renderer/context/list-context'; +import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters'; +import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh'; +import { + GenreListFilter, + useCurrentServer, + useListStoreActions, + useListStoreByKey, +} from '/@/renderer/store'; +import { ListDisplayType } from '/@/renderer/types'; + +interface GenreListHeaderProps { + gridRef: MutableRefObject; + itemCount?: number; + tableRef: MutableRefObject; +} + +export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeaderProps) => { + const cq = useContainerQuery(); + const server = useCurrentServer(); + const { pageKey } = useListContext(); + const { display, filter } = useListStoreByKey({ key: pageKey }); + const { setFilter, setTablePagination } = useListStoreActions(); + + const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemType: LibraryItem.GENRE, + server, + }); + + const handleSearch = debounce((e: ChangeEvent) => { + const searchTerm = e.target.value === '' ? undefined : e.target.value; + const updatedFilters = setFilter({ + data: { searchTerm }, + itemType: LibraryItem.GENRE, + key: pageKey, + }) as GenreListFilter; + + const filterWithCustom = { + ...updatedFilters, + }; + + if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { + handleRefreshTable(tableRef, filterWithCustom); + setTablePagination({ data: { currentPage: 0 }, key: pageKey }); + } else { + handleRefreshGrid(gridRef, filterWithCustom); + } + }, 500); + return ( + + + + + Genres + + {itemCount} + + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/genres/components/genre-list-table-view.tsx b/src/renderer/features/genres/components/genre-list-table-view.tsx new file mode 100644 index 00000000..4931ff58 --- /dev/null +++ b/src/renderer/features/genres/components/genre-list-table-view.tsx @@ -0,0 +1,41 @@ +import { LibraryItem } from '/@/renderer/api/types'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; +import { VirtualTable } from '/@/renderer/components/virtual-table'; +import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; +import { useListContext } from '/@/renderer/context/list-context'; +import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import { useCurrentServer } from '/@/renderer/store'; +import { MutableRefObject } from 'react'; + +interface GenreListTableViewProps { + itemCount?: number; + tableRef: MutableRefObject; +} + +export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewProps) => { + const server = useCurrentServer(); + const { pageKey, customFilters } = useListContext(); + + const tableProps = useVirtualTable({ + contextMenu: ALBUM_CONTEXT_MENU_ITEMS, + customFilters, + itemCount, + itemType: LibraryItem.GENRE, + pageKey, + server, + tableRef, + }); + + return ( + + + + ); +}; diff --git a/src/renderer/features/genres/queries/genre-list-query.ts b/src/renderer/features/genres/queries/genre-list-query.ts index 5b8b1b9e..955372f4 100644 --- a/src/renderer/features/genres/queries/genre-list-query.ts +++ b/src/renderer/features/genres/queries/genre-list-query.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { controller } from '/@/renderer/api/controller'; +import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import type { GenreListQuery } from '/@/renderer/api/types'; import type { QueryHookArgs } from '/@/renderer/lib/react-query'; @@ -14,7 +14,7 @@ export const useGenreList = (args: QueryHookArgs) => { enabled: !!server, queryFn: ({ signal }) => { if (!server) throw new Error('Server not found'); - return controller.getGenreList({ apiClientProps: { server, signal }, query }); + return api.controller.getGenreList({ apiClientProps: { server, signal }, query }); }, queryKey: queryKeys.genres.list(server?.id || ''), staleTime: 1000 * 60 * 60, diff --git a/src/renderer/features/genres/routes/genre-list-route.tsx b/src/renderer/features/genres/routes/genre-list-route.tsx new file mode 100644 index 00000000..2395a1c6 --- /dev/null +++ b/src/renderer/features/genres/routes/genre-list-route.tsx @@ -0,0 +1,96 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { useCallback, useMemo, useRef } from 'react'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { ListContext } from '/@/renderer/context/list-context'; +import { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content'; +import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header'; +import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-query'; +import { usePlayQueueAdd } from '/@/renderer/features/player'; +import { AnimatedPage } from '/@/renderer/features/shared'; +import { queryClient } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; +import { Play } from '/@/renderer/types'; + +const GenreListRoute = () => { + const gridRef = useRef(null); + const tableRef = useRef(null); + const server = useCurrentServer(); + const handlePlayQueueAdd = usePlayQueueAdd(); + const pageKey = 'genre'; + + const itemCountCheck = useGenreList({ + query: { + limit: 1, + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId: server?.id, + }); + + const itemCount = + itemCountCheck.data?.totalRecordCount === null + ? undefined + : itemCountCheck.data?.totalRecordCount; + + const handlePlay = useCallback( + async (args: { initialSongId?: string; playType: Play }) => { + if (!itemCount || itemCount === 0) return; + const { playType } = args; + const query = { + startIndex: 0, + }; + const queryKey = queryKeys.albums.list(server?.id || '', query); + + const albumListRes = await queryClient.fetchQuery({ + queryFn: ({ signal }) => { + return api.controller.getAlbumList({ + apiClientProps: { server, signal }, + query, + }); + }, + queryKey, + }); + + const albumIds = albumListRes?.items?.map((a) => a.id) || []; + + handlePlayQueueAdd?.({ + byItemType: { + id: albumIds, + type: LibraryItem.ALBUM, + }, + playType, + }); + }, + [handlePlayQueueAdd, itemCount, server], + ); + + const providerValue = useMemo(() => { + return { + handlePlay, + pageKey, + }; + }, [handlePlay]); + + return ( + + + + + + + ); +}; + +export default GenreListRoute; diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 16ee6055..d00daae2 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -2,7 +2,7 @@ import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; import { useListFilterByKey } from '../../../store/list.store'; -import { LibraryItem } from '/@/renderer/api/types'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListStoreActions } from '/@/renderer/store'; @@ -24,7 +24,14 @@ export const JellyfinSongFilters = ({ const { filter } = useListFilterByKey({ key: pageKey }); // TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library - const genreListQuery = useGenreList({ query: null, serverId }); + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index e62591fb..1632fb38 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -1,7 +1,7 @@ import { Divider, Group, Stack } from '@mantine/core'; import debounce from 'lodash/debounce'; import { ChangeEvent, useMemo } from 'react'; -import { LibraryItem } from '/@/renderer/api/types'; +import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; import { NumberInput, Select, Switch, Text } from '/@/renderer/components'; import { useGenreList } from '/@/renderer/features/genres'; import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store'; @@ -22,7 +22,14 @@ export const NavidromeSongFilters = ({ const { setFilter } = useListStoreActions(); const filter = useListFilterByKey({ key: pageKey }); - const genreListQuery = useGenreList({ query: null, serverId }); + const genreListQuery = useGenreList({ + query: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }, + serverId, + }); const genreList = useMemo(() => { if (!genreListQuery?.data) return []; diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 716ce4fa..91ccb000 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -1,18 +1,24 @@ +import { MutableRefObject, useCallback, useMemo } from 'react'; import { IDatasource } from '@ag-grid-community/core'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { QueryKey, useQueryClient } from '@tanstack/react-query'; -import { MutableRefObject, useCallback, useMemo } from 'react'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import orderBy from 'lodash/orderBy'; interface UseHandleListFilterChangeProps { + isClientSideSort?: boolean; itemType: LibraryItem; server: ServerListItem | null; } -export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterChangeProps) => { +export const useListFilterRefresh = ({ + server, + itemType, + isClientSideSort, +}: UseHandleListFilterChangeProps) => { const queryClient = useQueryClient(); const queryKeyFn: ((serverId: string, query: Record) => QueryKey) | null = @@ -29,6 +35,9 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh if (itemType === LibraryItem.SONG) { return queryKeys.songs.list; } + if (itemType === LibraryItem.GENRE) { + return queryKeys.genres.list; + } return null; }, [itemType]); @@ -47,6 +56,9 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh if (itemType === LibraryItem.SONG) { return api.controller.getSongList; } + if (itemType === LibraryItem.GENRE) { + return api.controller.getGenreList; + } return null; }, [itemType]); @@ -79,6 +91,17 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh queryKey, }); + if (isClientSideSort && res?.items) { + const sortedResults = orderBy( + res.items, + [(item) => String(item[filter.sortBy]).toLowerCase()], + filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], + ); + + params.successCallback(sortedResults || [], res?.totalRecordCount || 0); + return; + } + params.successCallback(res?.items || [], res?.totalRecordCount || 0); }, @@ -89,7 +112,7 @@ export const useListFilterRefresh = ({ server, itemType }: UseHandleListFilterCh tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.ensureIndexVisible(0, 'top'); }, - [queryClient, queryFn, queryKeyFn, server], + [isClientSideSort, queryClient, queryFn, queryKeyFn, server], ); const handleRefreshGrid = useCallback( diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 4341ed94..496f9812 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -58,6 +58,8 @@ const AlbumDetailRoute = lazy( () => import('/@/renderer/features/albums/routes/album-detail-route'), ); +const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route')); + const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route')); const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route')); @@ -103,6 +105,11 @@ export const AppRouter = () => { errorElement={} path={AppRoute.NOW_PLAYING} /> + } + errorElement={} + path={AppRoute.LIBRARY_GENRES} + /> } errorElement={} diff --git a/src/renderer/store/list.store.ts b/src/renderer/store/list.store.ts index e883ca5e..12b04ae9 100644 --- a/src/renderer/store/list.store.ts +++ b/src/renderer/store/list.store.ts @@ -8,6 +8,8 @@ import { AlbumArtistListSort, AlbumListArgs, AlbumListSort, + GenreListArgs, + GenreListSort, LibraryItem, PlaylistListArgs, PlaylistListSort, @@ -26,10 +28,14 @@ export type AlbumListFilter = Omit; export type AlbumArtistListFilter = Omit; export type PlaylistListFilter = Omit; +export type GenreListFilter = Omit; -export type ListKey = keyof ListState['item'] | string; - -type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter | PlaylistListFilter; +type FilterType = + | AlbumListFilter + | SongListFilter + | AlbumArtistListFilter + | PlaylistListFilter + | GenreListFilter; export type ListTableProps = { pagination: TablePagination; @@ -58,11 +64,14 @@ export interface ListState { albumArtistAlbum: ListItemProps; albumArtistSong: ListItemProps; albumDetail: ListItemProps; + genre: ListItemProps; playlist: ListItemProps; song: ListItemProps; }; } +export type ListKey = keyof ListState['item'] | string; + export type ListDeterministicArgs = { key: ListKey }; export interface ListSlice extends ListState { @@ -479,6 +488,35 @@ export const useListStore = create()( scrollOffset: 0, }, }, + genre: { + display: ListDisplayType.TABLE, + filter: { + sortBy: GenreListSort.NAME, + sortOrder: SortOrder.ASC, + }, + grid: { itemsPerRow: 5, scrollOffset: 0 }, + table: { + autoFit: true, + columns: [ + { + column: TableColumn.ROW_INDEX, + width: 50, + }, + { + column: TableColumn.TITLE, + width: 500, + }, + ], + pagination: { + currentPage: 1, + itemsPerPage: 100, + totalItems: 1, + totalPages: 1, + }, + rowHeight: 30, + scrollOffset: 0, + }, + }, playlist: { display: ListDisplayType.POSTER, filter: {