add new genre list

This commit is contained in:
jeffvli
2025-11-13 21:45:09 -08:00
parent e79f4dad75
commit 6af47670d9
14 changed files with 642 additions and 469 deletions
+13
View File
@@ -159,6 +159,19 @@ export const queryKeys: Record<
root: (serverId: string) => [serverId, 'artists'] as const, root: (serverId: string) => [serverId, 'artists'] as const,
}, },
genres: { genres: {
count: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'genres', 'count', filter, pagination] as const;
}
if (query) {
return [serverId, 'genres', 'count', filter] as const;
}
return [serverId, 'genres', 'count'] as const;
},
list: (serverId: string, query?: GenreListQuery) => { list: (serverId: string, query?: GenreListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query); const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) { if (query && pagination) {
@@ -435,27 +435,27 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
width: 80, width: 80,
}, },
{ {
align: 'start', align: 'center',
autoSize: false, autoSize: false,
isEnabled: true, isEnabled: true,
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.image', { postProcess: 'titleCase' }),
pinned: null, pinned: 'left',
value: TableColumn.TITLE, value: TableColumn.IMAGE,
width: 300, width: 70,
}, },
{ {
align: 'start', align: 'start',
autoSize: false, autoSize: false,
isEnabled: false, isEnabled: true,
label: i18n.t('table.config.label.titleCombined', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null, pinned: 'left',
value: TableColumn.TITLE_COMBINED, value: TableColumn.TITLE,
width: 300, width: 300,
}, },
{ {
align: 'center', align: 'center',
autoSize: false, autoSize: false,
isEnabled: true, isEnabled: false,
label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.duration', { postProcess: 'titleCase' }),
pinned: null, pinned: null,
value: TableColumn.DURATION, value: TableColumn.DURATION,
@@ -473,7 +473,7 @@ export const ALBUM_ARTIST_TABLE_COLUMNS: DefaultTableColumn[] = [
{ {
align: 'start', align: 'start',
autoSize: false, autoSize: false,
isEnabled: true, isEnabled: false,
label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.genre', { postProcess: 'titleCase' }),
pinned: null, pinned: null,
value: TableColumn.GENRE, value: TableColumn.GENRE,
@@ -631,7 +631,7 @@ export const GENRE_TABLE_COLUMNS: DefaultTableColumn[] = [
}, },
{ {
align: 'start', align: 'start',
autoSize: false, autoSize: true,
isEnabled: true, isEnabled: true,
label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.title', { postProcess: 'titleCase' }),
pinned: null, pinned: null,
+18 -1
View File
@@ -3,7 +3,7 @@ import { queryOptions } from '@tanstack/react-query';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { GenreListQuery } from '/@/shared/types/domain-types'; import { GenreListQuery, ListCountQuery } from '/@/shared/types/domain-types';
export const genresQueries = { export const genresQueries = {
list: (args: QueryHookArgs<GenreListQuery>) => { list: (args: QueryHookArgs<GenreListQuery>) => {
@@ -19,4 +19,21 @@ export const genresQueries = {
...args.options, ...args.options,
}); });
}, },
listCount: (args: QueryHookArgs<ListCountQuery<GenreListQuery>>) => {
return queryOptions({
queryFn: ({ signal }) => {
return api.controller
.getGenreList({
apiClientProps: { serverId: args.serverId, signal },
query: { ...args.query, limit: 1, startIndex: 0 },
})
.then((result) => result?.totalRecordCount ?? 0);
},
queryKey: queryKeys.genres.count(
args.serverId,
Object.keys(args.query).length === 0 ? undefined : args.query,
),
...args.options,
});
},
}; };
@@ -1,42 +1,129 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { lazy, Suspense } from 'react';
import { lazy, MutableRefObject, Suspense } from 'react'; import { useGenreListFilters } from '/@/renderer/features/genres/hooks/use-genre-list-filters';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { ListDisplayType } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';
const GenreListGridView = lazy(() => const GenreListInfiniteGrid = lazy(() =>
import('/@/renderer/features/genres/components/genre-list-grid-view').then((module) => ({ import('/@/renderer/features/genres/components/genre-list-infinite-grid').then((module) => ({
default: module.GenreListGridView, default: module.GenreListInfiniteGrid,
})), })),
); );
const GenreListTableView = lazy(() => const GenreListPaginatedGrid = lazy(() =>
import('/@/renderer/features/genres/components/genre-list-table-view').then((module) => ({ import('/@/renderer/features/genres/components/genre-list-paginated-grid').then((module) => ({
default: module.GenreListTableView, default: module.GenreListPaginatedGrid,
})), })),
); );
interface AlbumListContentProps { const GenreListInfiniteTable = lazy(() =>
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>; import('/@/renderer/features/genres/components/genre-list-infinite-table').then((module) => ({
itemCount?: number; default: module.GenreListInfiniteTable,
tableRef: MutableRefObject<AgGridReactType | null>; })),
} );
export const GenreListContent = ({ gridRef, itemCount, tableRef }: AlbumListContentProps) => { const GenreListPaginatedTable = lazy(() =>
const { pageKey } = useListContext(); import('/@/renderer/features/genres/components/genre-list-paginated-table').then((module) => ({
const { display } = useListStoreByKey({ key: pageKey }); default: module.GenreListPaginatedTable,
})),
);
export const GenreListContent = () => {
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.GENRE);
return ( return (
<Suspense fallback={<Spinner container />}> <Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? ( <GenreListView
<GenreListGridView gridRef={gridRef} itemCount={itemCount} /> display={display}
) : ( grid={grid}
<GenreListTableView itemCount={itemCount} tableRef={tableRef} /> itemsPerPage={itemsPerPage}
)} pagination={pagination}
table={table}
/>
</Suspense> </Suspense>
); );
}; };
export const GenreListView = ({
display,
grid,
itemsPerPage,
pagination,
table,
}: ItemListSettings) => {
const server = useCurrentServer();
const { query } = useGenreListFilters();
switch (display) {
case ListDisplayType.GRID: {
switch (pagination) {
case ListPaginationType.INFINITE: {
return (
<GenreListInfiniteGrid
gap={grid.itemGap}
itemsPerPage={itemsPerPage}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={query}
serverId={server.id}
/>
);
}
case ListPaginationType.PAGINATED: {
return (
<GenreListPaginatedGrid
gap={grid.itemGap}
itemsPerPage={itemsPerPage}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
query={query}
serverId={server.id}
/>
);
}
default:
return null;
}
}
case ListDisplayType.TABLE: {
switch (pagination) {
case ListPaginationType.INFINITE: {
return (
<GenreListInfiniteTable
autoFitColumns={table.autoFitColumns}
columns={table.columns}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
itemsPerPage={itemsPerPage}
query={query}
serverId={server.id}
size={table.size}
/>
);
}
case ListPaginationType.PAGINATED: {
return (
<GenreListPaginatedTable
autoFitColumns={table.autoFitColumns}
columns={table.columns}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
itemsPerPage={itemsPerPage}
query={query}
serverId={server.id}
size={table.size}
/>
);
}
default:
return null;
}
}
}
return null;
};
@@ -1,354 +1,40 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { queryKeys } from '/@/renderer/api/query-keys';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid';
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { FolderButton } from '/@/renderer/features/shared/components/folder-button';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { MoreButton } from '/@/renderer/features/shared/components/more-button'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
import { OrderToggleButton } from '/@/renderer/features/shared/components/order-toggle-button'; import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown';
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { useContainerQuery } from '/@/renderer/hooks'; import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import {
GenreListFilter,
GenreTarget,
PersistedTableColumn,
useCurrentServer,
useGeneralSettings,
useListStoreActions,
useListStoreByKey,
useSettingsStoreActions,
} from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { GenreListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
import { import { ItemListKey } from '/@/shared/types/types';
GenreListQuery,
GenreListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType } from '/@/shared/types/types';
const FILTERS = { export const GenreListHeaderFilters = () => {
jellyfin: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
navidrome: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
};
interface GenreListHeaderFiltersProps {
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const GenreListHeaderFilters = ({
gridRef,
itemCount,
tableRef,
}: GenreListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { customFilters, pageKey } = useListContext();
const server = useCurrentServer();
const { setDisplayType, setFilter, setGrid, setTable } = useListStoreActions();
const { display, filter, grid, table } = useListStoreByKey<GenreListQuery>({ key: pageKey });
const cq = useContainerQuery(); const cq = useContainerQuery();
const { genreTarget } = useGeneralSettings();
const { setGenreBehavior } = useSettingsStoreActions();
const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.GENRE,
server,
});
const musicFoldersQuery = useQuery(
sharedQueries.musicFolders({ query: null, serverId: server?.id }),
);
const sortByLabel =
(server?.type &&
FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy)
?.name) ||
'Unknown';
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
const onFilterChange = useCallback(
(filter: GenreListFilter) => {
if (isGrid) {
handleRefreshGrid(gridRef, {
...filter,
...customFilters,
});
} else {
handleRefreshTable(tableRef, {
...filter,
...customFilters,
});
}
},
[customFilters, gridRef, handleRefreshGrid, handleRefreshTable, isGrid, tableRef],
);
const handleRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.genres.list(server?.id || '') });
onFilterChange(filter);
}, [filter, onFilterChange, queryClient, server?.id]);
const handleSetSortBy = useCallback(
(e: MouseEvent<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 handleSetMusicFolder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
let updatedFilters: GenreListFilter | null = null;
if (e.currentTarget.value === String(filter.musicFolderId)) {
updatedFilters = setFilter({
customFilters,
data: { musicFolderId: undefined },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
} else {
updatedFilters = setFilter({
customFilters,
data: { musicFolderId: e.currentTarget.value },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
}
onFilterChange(updatedFilters);
},
[filter.musicFolderId, onFilterChange, setFilter, customFilters, pageKey],
);
const handleToggleSortOrder = useCallback(() => {
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
const updatedFilters = setFilter({
customFilters,
data: { sortOrder: newSortOrder },
itemType: LibraryItem.GENRE,
key: pageKey,
}) as GenreListFilter;
onFilterChange(updatedFilters);
}, [customFilters, filter.sortOrder, onFilterChange, pageKey, setFilter]);
const handleItemSize = (e: number) => {
if (isGrid) {
setGrid({ data: { itemSize: e }, key: pageKey });
} else {
setTable({ data: { rowHeight: e }, key: pageKey });
}
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleItemGap = (e: number) => {
setGrid({ data: { itemGap: e }, key: pageKey });
};
const handleSetViewType = useCallback(
(displayType: ListDisplayType) => {
setDisplayType({ data: displayType, key: pageKey });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: string[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
return setTable({
data: { columns: [] },
key: pageKey,
});
}
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = {
column: values[values.length - 1],
width: 100,
} as PersistedTableColumn;
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
// If removing a column
const removed = existingColumns.filter((column) => !values.includes(column.column));
const newColumns = existingColumns.filter((column) => !removed.includes(column));
setTable({ data: { columns: newColumns }, key: pageKey });
}
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (autoFitColumns: boolean) => {
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
if (autoFitColumns) {
tableRef.current?.api.sizeColumnsToFit();
}
};
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;
}, [filter.musicFolderId]);
const handleGenreToggle = useCallback(() => {
const newState = genreTarget === GenreTarget.ALBUM ? GenreTarget.TRACK : GenreTarget.ALBUM;
setGenreBehavior(newState);
}, [genreTarget, setGenreBehavior]);
return ( return (
<Flex justify="space-between"> <Flex justify="space-between">
<Group gap="sm" ref={cq.ref} w="100%"> <Group gap="sm" ref={cq.ref} w="100%">
<DropdownMenu position="bottom-start"> <ListSortByDropdown
<DropdownMenu.Target> defaultSortByValue={GenreListSort.NAME}
<Button variant="subtle">{sortByLabel}</Button> itemType={LibraryItem.GENRE}
</DropdownMenu.Target> listKey={ItemListKey.GENRE}
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
isSelected={f.value === filter.sortBy}
key={`filter-${f.name}`}
onClick={handleSetSortBy}
value={f.value}
>
{f.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton onToggle={handleToggleSortOrder} sortOrder={filter.sortOrder} />
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<FolderButton
isActive={isFolderFilterApplied}
onClick={handleSetMusicFolder}
/> />
</DropdownMenu.Target> <Divider orientation="vertical" />
<DropdownMenu.Dropdown> <ListSortOrderToggleButton
{musicFoldersQuery.data?.items.map((folder) => ( defaultSortOrder={SortOrder.ASC}
<DropdownMenu.Item listKey={ItemListKey.GENRE}
isSelected={filter.musicFolderId === folder.id} />
key={`musicFolder-${folder.id}`} <ListMusicFolderDropdown listKey={ItemListKey.GENRE} />
onClick={handleSetMusicFolder} <ListFilters itemType={LibraryItem.GENRE} />
value={folder.id} <ListRefreshButton listKey={ItemListKey.GENRE} />
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
</>
)}
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('common.refresh', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
<Button
onClick={handleGenreToggle}
size="compact-md"
tooltip={{
label: t(
genreTarget === GenreTarget.ALBUM
? 'page.genreList.showTracks'
: 'page.genreList.showAlbums',
{ postProcess: 'sentenceCase' },
),
}}
variant="subtle"
>
{genreTarget === GenreTarget.ALBUM ? (
<Icon icon="itemAlbum" />
) : (
<Icon icon="itemSong" />
)}
</Button>
</DropdownMenu>
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<ListConfigMenu <ListConfigMenu
autoFitColumns={table.autoFit} listKey={ItemListKey.GENRE}
disabledViewTypes={[ListDisplayType.LIST]}
displayType={display}
itemGap={grid?.itemGap || 0}
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
onChangeAutoFitColumns={handleAutoFitColumns}
onChangeDisplayType={handleSetViewType}
onChangeItemGap={handleItemGap}
onChangeItemSize={debouncedHandleItemSize}
onChangeTableColumns={handleTableColumns}
tableColumns={table?.columns.map((column) => column.column)}
tableColumnsData={GENRE_TABLE_COLUMNS} tableColumnsData={GENRE_TABLE_COLUMNS}
/> />
</Group> </Group>
@@ -1,70 +1,40 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import debounce from 'lodash/debounce';
import { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PageHeader } from '/@/renderer/components/page-header/page-header'; import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid/virtual-infinite-grid'; import { useListContext } from '/@/renderer/context/list-context';
import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters'; import { GenreListHeaderFilters } from '/@/renderer/features/genres/components/genre-list-header-filters';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { SearchInput } from '/@/renderer/features/shared/components/search-input'; import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { useContainerQuery } from '/@/renderer/hooks';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
import { GenreListFilter, useCurrentServer } from '/@/renderer/store';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { GenreListQuery, LibraryItem } from '/@/shared/types/domain-types';
interface GenreListHeaderProps { interface GenreListHeaderProps {
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>; title?: string;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
} }
export const GenreListHeader = ({ gridRef, itemCount, tableRef }: GenreListHeaderProps) => { export const GenreListHeader = ({ title }: GenreListHeaderProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const cq = useContainerQuery();
const server = useCurrentServer();
const { filter, refresh, search } = useDisplayRefresh<GenreListQuery>({
gridRef,
itemType: LibraryItem.GENRE,
server,
tableRef,
});
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => { const { itemCount } = useListContext();
const updatedFilters = search(e) as GenreListFilter; const pageTitle = title || t('page.genreList.title', { postProcess: 'titleCase' });
refresh(updatedFilters);
}, 500);
return ( return (
<Stack gap={0} ref={cq.ref}> <Stack gap={0}>
<PageHeader> <PageHeader backgroundColor="var(--theme-colors-background)">
<Flex justify="space-between" w="100%">
<LibraryHeaderBar> <LibraryHeaderBar>
<LibraryHeaderBar.Title> <LibraryHeaderBar.PlayButton />
{t('page.genreList.title', { postProcess: 'titleCase' })} <LibraryHeaderBar.Title>{pageTitle}</LibraryHeaderBar.Title>
</LibraryHeaderBar.Title> <LibraryHeaderBar.Badge isLoading={!itemCount}>
<LibraryHeaderBar.Badge
isLoading={itemCount === null || itemCount === undefined}
>
{itemCount} {itemCount}
</LibraryHeaderBar.Badge> </LibraryHeaderBar.Badge>
</LibraryHeaderBar> </LibraryHeaderBar>
<Group> <Group>
<SearchInput defaultValue={filter.searchTerm} onChange={handleSearch} /> <ListSearchInput />
</Group> </Group>
</Flex>
</PageHeader> </PageHeader>
<FilterBar> <FilterBar>
<GenreListHeaderFilters <GenreListHeaderFilters />
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar> </FilterBar>
</Stack> </Stack>
); );
@@ -0,0 +1,68 @@
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
import { forwardRef } from 'react';
import { api } from '/@/renderer/api';
import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import {
GenreListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface GenreListInfiniteGridProps extends ItemListGridComponentProps<GenreListQuery> {}
export const GenreListInfiniteGrid = forwardRef<any, GenreListInfiniteGridProps>(
(
{
gap = 'md',
itemsPerPage = 100,
itemsPerRow,
query = {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
},
saveScrollOffset = true,
serverId,
},
ref,
) => {
const listCountQuery = genresQueries.listCount({
query: { ...query },
serverId: serverId,
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getGenreList;
const { data, onRangeChanged } = useItemListInfiniteLoader({
eventKey: ItemListKey.GENRE,
itemsPerPage,
itemType: LibraryItem.GENRE,
listCountQuery,
listQueryFn,
query,
serverId,
});
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
return (
<ItemGridList
data={data}
gap={gap}
initialTop={scrollOffset ?? 0}
itemsPerRow={itemsPerRow}
itemType={LibraryItem.GENRE}
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
/>
);
},
);
@@ -0,0 +1,87 @@
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
import { forwardRef } from 'react';
import { api } from '/@/renderer/api';
import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import {
GenreListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface GenreListInfiniteTableProps extends ItemListTableComponentProps<GenreListQuery> {}
export const GenreListInfiniteTable = forwardRef<any, GenreListInfiniteTableProps>(
(
{
autoFitColumns = false,
columns,
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
},
saveScrollOffset = true,
serverId,
size = 'default',
},
ref,
) => {
const listCountQuery = genresQueries.listCount({
query: { ...query },
serverId: serverId,
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getGenreList;
const { data, onRangeChanged } = useItemListInfiniteLoader({
eventKey: ItemListKey.GENRE,
itemsPerPage,
itemType: LibraryItem.GENRE,
listCountQuery,
listQueryFn,
query,
serverId,
});
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
return (
<ItemTableList
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={data}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.GENRE}
onRangeChanged={onRangeChanged}
onScrollEnd={handleOnScrollEnd}
ref={ref}
size={size}
/>
);
},
);
@@ -0,0 +1,83 @@
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
import { forwardRef } from 'react';
import { api } from '/@/renderer/api';
import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import {
GenreListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
interface GenreListPaginatedGridProps extends ItemListGridComponentProps<GenreListQuery> {}
export const GenreListPaginatedGrid = forwardRef<any, GenreListPaginatedGridProps>(
(
{
gap = 'md',
itemsPerPage = 100,
itemsPerRow,
query = {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
},
saveScrollOffset = true,
serverId,
},
ref,
) => {
const listCountQuery = genresQueries.listCount({
query: { ...query },
serverId: serverId,
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getGenreList;
const { currentPage, onChange } = useItemListPagination();
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
itemsPerPage,
itemType: LibraryItem.GENRE,
listCountQuery,
listQueryFn,
query,
serverId,
});
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onChange}
pageCount={pageCount}
totalItemCount={totalItemCount}
>
<ItemGridList
currentPage={currentPage}
data={data || []}
gap={gap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={itemsPerRow}
itemType={LibraryItem.GENRE}
onScrollEnd={handleOnScrollEnd}
ref={ref}
/>
</ItemListWithPagination>
);
},
);
@@ -0,0 +1,98 @@
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
import { forwardRef } from 'react';
import { api } from '/@/renderer/api';
import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import {
GenreListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
interface GenreListPaginatedTableProps extends ItemListTableComponentProps<GenreListQuery> {}
export const GenreListPaginatedTable = forwardRef<any, GenreListPaginatedTableProps>(
(
{
autoFitColumns = false,
columns,
enableAlternateRowColors = false,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
itemsPerPage = 100,
query = {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
},
saveScrollOffset = true,
serverId,
size = 'default',
},
ref,
) => {
const listCountQuery = genresQueries.listCount({
query: { ...query },
serverId: serverId,
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
const listQueryFn = api.controller.getGenreList;
const { currentPage, onChange } = useItemListPagination();
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
currentPage,
itemsPerPage,
itemType: LibraryItem.GENRE,
listCountQuery,
listQueryFn,
query,
serverId,
});
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onChange}
pageCount={pageCount}
totalItemCount={totalItemCount}
>
<ItemTableList
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
currentPage={currentPage}
data={data || []}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.GENRE}
onScrollEnd={handleOnScrollEnd}
ref={ref}
size={size}
/>
</ItemListWithPagination>
);
},
);
@@ -0,0 +1,29 @@
import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { GenreListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export const useGenreListFilters = () => {
const { sortBy } = useSortByFilter<GenreListSort>(null, ItemListKey.GENRE);
const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE);
const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.GENRE);
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
const query = {
[FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined,
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
};
return {
query,
setSearchTerm,
};
};
@@ -1,50 +1,30 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { ListContext } from '/@/renderer/context/list-context'; import { ListContext } from '/@/renderer/context/list-context';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content';
import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header'; import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { useCurrentServer } from '/@/renderer/store'; import { ItemListKey } from '/@/shared/types/types';
import { useListStoreByKey } from '/@/renderer/store/list.store';
import { GenreListQuery } from '/@/shared/types/domain-types';
const GenreListRoute = () => { const GenreListRoute = () => {
const gridRef = useRef<null>(null); const pageKey = ItemListKey.GENRE;
const tableRef = useRef<AgGridReactType | null>(null);
const server = useCurrentServer();
const pageKey = 'genre';
const { filter } = useListStoreByKey<GenreListQuery>({ key: pageKey });
const itemCountCheck = useQuery( const [itemCount, setItemCount] = useState<number | undefined>(undefined);
genresQueries.list({
query: {
...filter,
limit: 1,
startIndex: 0,
},
serverId: server?.id,
}),
);
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const providerValue = useMemo(() => { const providerValue = useMemo(() => {
return { return {
id: undefined,
itemCount,
pageKey, pageKey,
setItemCount,
}; };
}, []); }, [itemCount, pageKey, setItemCount]);
return ( return (
<AnimatedPage> <AnimatedPage>
<ListContext.Provider value={providerValue}> <ListContext.Provider value={providerValue}>
<GenreListHeader gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} /> <GenreListHeader />
{/* <GenreListContent gridRef={gridRef} itemCount={itemCount} tableRef={tableRef} /> */} <GenreListContent />
</ListContext.Provider> </ListContext.Provider>
</AnimatedPage> </AnimatedPage>
); );
@@ -6,6 +6,7 @@ import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListSort, AlbumListSort,
GenreListSort,
LibraryItem, LibraryItem,
ServerType, ServerType,
SongListSort, SongListSort,
@@ -444,8 +445,35 @@ const ALBUM_ARTIST_LIST_FILTERS: Partial<
], ],
}; };
const GENRE_LIST_FILTERS: Partial<
Record<ServerType, Array<{ defaultOrder: SortOrder; name: string; value: string }>>
> = {
[ServerType.JELLYFIN]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
[ServerType.NAVIDROME]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
[ServerType.SUBSONIC]: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: GenreListSort.NAME,
},
],
};
const FILTERS: Partial<Record<LibraryItem, any>> = { const FILTERS: Partial<Record<LibraryItem, any>> = {
[LibraryItem.ALBUM]: ALBUM_LIST_FILTERS, [LibraryItem.ALBUM]: ALBUM_LIST_FILTERS,
[LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS, [LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS,
[LibraryItem.GENRE]: GENRE_LIST_FILTERS,
[LibraryItem.SONG]: SONG_LIST_FILTERS, [LibraryItem.SONG]: SONG_LIST_FILTERS,
}; };
+42 -15
View File
@@ -8,13 +8,14 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { import {
ALBUM_ARTIST_TABLE_COLUMNS,
ALBUM_TABLE_COLUMNS, ALBUM_TABLE_COLUMNS,
GENRE_TABLE_COLUMNS,
pickTableColumns, pickTableColumns,
PLAYLIST_SONG_TABLE_COLUMNS, PLAYLIST_SONG_TABLE_COLUMNS,
PLAYLIST_TABLE_COLUMNS, PLAYLIST_TABLE_COLUMNS,
SONG_TABLE_COLUMNS, SONG_TABLE_COLUMNS,
} from '/@/renderer/components/item-list/item-table-list/default-columns'; } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table/table-config-dropdown';
import { ContextMenuItemType } from '/@/renderer/features/context-menu/events'; import { ContextMenuItemType } from '/@/renderer/features/context-menu/events';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { mergeOverridingColumns } from '/@/renderer/store/utils'; import { mergeOverridingColumns } from '/@/renderer/store/utils';
@@ -706,13 +707,13 @@ const initialState: SettingsState = {
pagination: ListPaginationType.INFINITE, pagination: ListPaginationType.INFINITE,
table: { table: {
autoFitColumns: false, autoFitColumns: false,
columns: ALBUMARTIST_TABLE_COLUMNS.map((column) => ({ columns: ALBUM_ARTIST_TABLE_COLUMNS.map((column) => ({
align: 'start' as const, align: column.align,
autoSize: false, autoSize: column.autoSize,
id: column.value, id: column.value,
isEnabled: true, isEnabled: column.isEnabled,
pinned: null, pinned: column.pinned,
width: 200, width: column.width,
})), })),
enableAlternateRowColors: true, enableAlternateRowColors: true,
enableHorizontalBorders: true, enableHorizontalBorders: true,
@@ -732,13 +733,13 @@ const initialState: SettingsState = {
pagination: ListPaginationType.INFINITE, pagination: ListPaginationType.INFINITE,
table: { table: {
autoFitColumns: false, autoFitColumns: false,
columns: ALBUMARTIST_TABLE_COLUMNS.map((column) => ({ columns: ALBUM_ARTIST_TABLE_COLUMNS.map((column) => ({
align: 'start' as const, align: column.align,
autoSize: false, autoSize: column.autoSize,
id: column.value, id: column.value,
isEnabled: true, isEnabled: column.isEnabled,
pinned: null, pinned: column.pinned,
width: 200, width: column.width,
})), })),
enableAlternateRowColors: true, enableAlternateRowColors: true,
enableHorizontalBorders: true, enableHorizontalBorders: true,
@@ -747,6 +748,32 @@ const initialState: SettingsState = {
size: 'default', size: 'default',
}, },
}, },
[LibraryItem.GENRE]: {
display: ListDisplayType.TABLE,
grid: {
itemGap: 'md',
itemsPerRow: 6,
itemsPerRowEnabled: false,
},
itemsPerPage: 100,
pagination: ListPaginationType.INFINITE,
table: {
autoFitColumns: false,
columns: GENRE_TABLE_COLUMNS.map((column) => ({
align: column.align,
autoSize: column.autoSize,
id: column.value,
isEnabled: column.isEnabled,
pinned: column.pinned,
width: column.width,
})),
enableAlternateRowColors: true,
enableHorizontalBorders: true,
enableRowHoverHighlight: true,
enableVerticalBorders: false,
size: 'compact',
},
},
[LibraryItem.PLAYLIST]: { [LibraryItem.PLAYLIST]: {
display: ListDisplayType.TABLE, display: ListDisplayType.TABLE,
grid: { grid: {
@@ -872,8 +899,8 @@ const initialState: SettingsState = {
TableColumn.USER_FAVORITE, TableColumn.USER_FAVORITE,
], ],
}), }),
enableAlternateRowColors: true, enableAlternateRowColors: false,
enableHorizontalBorders: true, enableHorizontalBorders: false,
enableRowHoverHighlight: true, enableRowHoverHighlight: true,
enableVerticalBorders: false, enableVerticalBorders: false,
size: 'default', size: 'default',