mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Add initial genre list support
This commit is contained in:
parent
4d5085f230
commit
8029712b55
25 changed files with 968 additions and 41 deletions
|
|
@ -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 [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue