Add initial genre list support
This commit is contained in:
parent
4d5085f230
commit
8029712b55
25 changed files with 968 additions and 41 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<MusicFolde
|
|||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
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<GenreListResponse> =>
|
|||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<UserListResponse> => {
|
|||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
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<GenreListResponse> =>
|
|||
|
||||
return {
|
||||
items: res.body.data,
|
||||
startIndex: 0,
|
||||
startIndex: query.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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<Genre[]> | 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<GenreListSort, JFGenreListSort | undefined>;
|
||||
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
|
||||
subsonic: Record<UserListSort, undefined>;
|
||||
};
|
||||
|
||||
export const genreListSortMap: GenreListSortMap = {
|
||||
jellyfin: {
|
||||
name: JFGenreListSort.NAME,
|
||||
},
|
||||
navidrome: {
|
||||
name: NDGenreListSort.NAME,
|
||||
},
|
||||
subsonic: {
|
||||
name: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Album List
|
||||
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
||||
|
|
|
@ -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<TResponse, TFilter> = (
|
|||
interface UseAgGridProps<TFilter> {
|
||||
contextMenu: SetContextMenuItems;
|
||||
customFilters?: Partial<TFilter>;
|
||||
isClientSideSort?: boolean;
|
||||
isSearchParams?: boolean;
|
||||
itemCount?: number;
|
||||
itemType: LibraryItem;
|
||||
|
@ -49,6 +51,7 @@ export const useVirtualTable = <TFilter>({
|
|||
itemCount,
|
||||
customFilters,
|
||||
isSearchParams,
|
||||
isClientSideSort,
|
||||
}: UseAgGridProps<TFilter>) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
@ -104,6 +107,9 @@ export const useVirtualTable = <TFilter>({
|
|||
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 = <TFilter>({
|
|||
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 = <TFilter>({
|
|||
return res;
|
||||
})) as BasePaginatedResponse<any>;
|
||||
|
||||
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 = <TFilter>({
|
|||
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 = <TFilter>({
|
|||
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 },
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -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<AlbumListFilter>;
|
||||
|
@ -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 [];
|
||||
|
|
|
@ -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<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
export const GenreListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
|
||||
const { pageKey } = useListContext();
|
||||
const { display } = useListStoreByKey({ key: pageKey });
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<GenreListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
/>
|
||||
) : (
|
||||
<GenreListTableView
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
116
src/renderer/features/genres/components/genre-list-grid-view.tsx
Normal file
116
src/renderer/features/genres/components/genre-list-grid-view.tsx
Normal file
|
@ -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>[] = [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 (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }: Size) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.GENRE}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
|
@ -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<VirtualInfiniteGridRef | null>;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
|
||||
|
||||
if (e.currentTarget.checked) {
|
||||
tableRef.current?.api.sizeColumnsToFit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
<Group
|
||||
ref={cq.ref}
|
||||
spacing="sm"
|
||||
w="100%"
|
||||
>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw={600}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortByLabel}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
|
||||
<DropdownMenu.Item
|
||||
key={`filter-${f.name}`}
|
||||
$isActive={f.value === filter.sortBy}
|
||||
value={f.value}
|
||||
onClick={handleSetSortBy}
|
||||
>
|
||||
{f.name}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Divider orientation="vertical" />
|
||||
<OrderToggleButton
|
||||
sortOrder={filter.sortOrder}
|
||||
onToggle={handleToggleSortOrder}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMoreFill size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item
|
||||
icon={<RiRefreshLine />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group
|
||||
noWrap
|
||||
spacing="sm"
|
||||
>
|
||||
<DropdownMenu
|
||||
position="bottom-end"
|
||||
width={425}
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
tooltip={{ label: 'Configure' }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiSettings3Fill size="1.3rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Label>Display type</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.CARD}
|
||||
value={ListDisplayType.CARD}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Card
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.POSTER}
|
||||
value={ListDisplayType.POSTER}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Poster
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
$isActive={display === ListDisplayType.TABLE}
|
||||
value={ListDisplayType.TABLE}
|
||||
onClick={handleSetViewType}
|
||||
>
|
||||
Table
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Label>
|
||||
{isGrid ? 'Items per row' : 'Item size'}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item closeMenuOnClick={false}>
|
||||
<Slider
|
||||
defaultValue={isGrid ? grid?.itemsPerRow || 0 : table.rowHeight}
|
||||
max={isGrid ? 14 : 100}
|
||||
min={isGrid ? 2 : 25}
|
||||
onChangeEnd={handleItemSize}
|
||||
/>
|
||||
</DropdownMenu.Item>
|
||||
{(display === ListDisplayType.TABLE ||
|
||||
display === ListDisplayType.TABLE_PAGINATED) && (
|
||||
<>
|
||||
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
closeMenuOnClick={false}
|
||||
component="div"
|
||||
sx={{ cursor: 'default' }}
|
||||
>
|
||||
<Stack>
|
||||
<MultiSelect
|
||||
clearable
|
||||
data={GENRE_TABLE_COLUMNS}
|
||||
defaultValue={table?.columns.map(
|
||||
(column) => column.column,
|
||||
)}
|
||||
width={300}
|
||||
onChange={handleTableColumns}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<Text>Auto Fit Columns</Text>
|
||||
<Switch
|
||||
defaultChecked={table.autoFit}
|
||||
onChange={handleAutoFitColumns}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
|
@ -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<VirtualInfiniteGridRef | null>;
|
||||
itemCount?: number;
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Stack
|
||||
ref={cq.ref}
|
||||
spacing={0}
|
||||
>
|
||||
<PageHeader backgroundColor="var(--titlebar-bg)">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
w="100%"
|
||||
>
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.Title>Genres</LibraryHeaderBar.Title>
|
||||
<LibraryHeaderBar.Badge
|
||||
isLoading={itemCount === null || itemCount === undefined}
|
||||
>
|
||||
{itemCount}
|
||||
</LibraryHeaderBar.Badge>
|
||||
</LibraryHeaderBar>
|
||||
<Group>
|
||||
<SearchInput
|
||||
defaultValue={filter.searchTerm}
|
||||
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</PageHeader>
|
||||
<FilterBar>
|
||||
<GenreListHeaderFilters
|
||||
gridRef={gridRef}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</FilterBar>
|
||||
</Stack>
|
||||
);
|
||||
};
|
|
@ -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<AgGridReactType | null>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${tableProps.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
{...tableProps}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
|
@ -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<GenreListQuery>) => {
|
|||
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,
|
||||
|
|
96
src/renderer/features/genres/routes/genre-list-route.tsx
Normal file
96
src/renderer/features/genres/routes/genre-list-route.tsx
Normal file
|
@ -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<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(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 (
|
||||
<AnimatedPage>
|
||||
<ListContext.Provider value={providerValue}>
|
||||
<GenreListHeader
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
<GenreListContent
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
</ListContext.Provider>
|
||||
</AnimatedPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenreListRoute;
|
|
@ -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 [];
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -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<any, any>) => 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(
|
||||
|
|
|
@ -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={<RouteErrorBoundary />}
|
||||
path={AppRoute.NOW_PLAYING}
|
||||
/>
|
||||
<Route
|
||||
element={<GenreListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
path={AppRoute.LIBRARY_GENRES}
|
||||
/>
|
||||
<Route
|
||||
element={<AlbumListRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
AlbumArtistListSort,
|
||||
AlbumListArgs,
|
||||
AlbumListSort,
|
||||
GenreListArgs,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
PlaylistListArgs,
|
||||
PlaylistListSort,
|
||||
|
@ -26,10 +28,14 @@ export type AlbumListFilter = Omit<AlbumListArgs['query'], 'startIndex' | 'limit
|
|||
export type SongListFilter = Omit<SongListArgs['query'], 'startIndex' | 'limit'>;
|
||||
export type AlbumArtistListFilter = Omit<AlbumArtistListArgs['query'], 'startIndex' | 'limit'>;
|
||||
export type PlaylistListFilter = Omit<PlaylistListArgs['query'], 'startIndex' | 'limit'>;
|
||||
export type GenreListFilter = Omit<GenreListArgs['query'], 'startIndex' | 'limit'>;
|
||||
|
||||
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<AlbumListFilter>;
|
||||
albumArtistSong: ListItemProps<SongListFilter>;
|
||||
albumDetail: ListItemProps<any>;
|
||||
genre: ListItemProps<GenreListFilter>;
|
||||
playlist: ListItemProps<PlaylistListFilter>;
|
||||
song: ListItemProps<SongListFilter>;
|
||||
};
|
||||
}
|
||||
|
||||
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<ListSlice>()(
|
|||
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: {
|
||||
|
|
Reference in a new issue