fix album list filters

This commit is contained in:
jeffvli
2025-11-30 15:32:18 -08:00
parent d75d1687a4
commit 6d87da2474
14 changed files with 441 additions and 174 deletions
@@ -299,6 +299,7 @@ export const NavidromeController: InternalControllerEndpoint = {
genre_id: genres, genre_id: genres,
library_id: getLibraryId(query.musicFolderId), library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm, name: query.searchTerm,
year: query.maxYear || query.minYear,
...query._custom, ...query._custom,
starred: query.favorite, starred: query.favorite,
...excludeMissing(apiClientProps.server), ...excludeMissing(apiClientProps.server),
@@ -2,9 +2,12 @@ import { lazy, Suspense, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store'; import { ItemListSettings, useCurrentServer, useListSettings } from '/@/renderer/store';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { AlbumListQuery } from '/@/shared/types/domain-types'; import { AlbumListQuery, LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types'; import { ItemListKey, ListDisplayType, ListPaginationType } from '/@/shared/types/types';
const AlbumListInfiniteGrid = lazy(() => const AlbumListInfiniteGrid = lazy(() =>
@@ -37,6 +40,12 @@ export const AlbumListContent = () => {
const { customFilters } = useListContext(); const { customFilters } = useListContext();
return ( return (
<>
<ListWithSidebarContainer.SidebarPortal>
<ScrollArea>
<ListFilters itemType={LibraryItem.ALBUM} />
</ScrollArea>
</ListWithSidebarContainer.SidebarPortal>
<Suspense fallback={<Spinner container />}> <Suspense fallback={<Spinner container />}>
<AlbumListView <AlbumListView
display={display} display={display}
@@ -47,6 +56,7 @@ export const AlbumListContent = () => {
table={table} table={table}
/> />
</Suspense> </Suspense>
</>
); );
}; };
@@ -77,6 +87,10 @@ export const AlbumListView = ({
}; };
}, [query, overrideQuery]); }, [query, overrideQuery]);
console.log('query', query);
console.log('overrideQuery', overrideQuery);
console.log('mergedQuery', mergedQuery);
switch (display) { switch (display) {
case ListDisplayType.GRID: { case ListDisplayType.GRID: {
switch (pagination) { switch (pagination) {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -65,7 +65,7 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
defaultSortOrder={SortOrder.ASC} defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.ALBUM} listKey={ItemListKey.ALBUM}
/> />
<ListFilters itemType={LibraryItem.ALBUM} /> <ListFiltersModal itemType={LibraryItem.ALBUM} />
<ListRefreshButton listKey={ItemListKey.ALBUM} /> <ListRefreshButton listKey={ItemListKey.ALBUM} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -1,6 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import debounce from 'lodash/debounce'; import { ChangeEvent, memo, useMemo } from 'react';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -11,19 +10,17 @@ import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer, useCurrentServerId } from '/@/renderer/store';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types'; import { titleCase } from '/@/renderer/utils';
import { hasFeature } from '/@/shared/api/utils';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { Spinner, SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select'; import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface NavidromeAlbumFiltersProps { interface NavidromeAlbumFiltersProps {
disableArtistFilter?: boolean; disableArtistFilter?: boolean;
@@ -38,7 +35,6 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
query, query,
setAlbumArtist, setAlbumArtist,
setCompilation, setCompilation,
setCustom,
setFavorite, setFavorite,
setGenreId, setGenreId,
setHasRating, setHasRating,
@@ -57,20 +53,8 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
})); }));
}, [genreListQuery.data]); }, [genreListQuery.data]);
const tagsQuery = useQuery( const yesNoUndefinedFilters = useMemo(
sharedQueries.tags({ () => [
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
type: LibraryItem.ALBUM,
},
serverId,
}),
);
const yesNoUndefinedFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite?: boolean) => { onChange: (favorite?: boolean) => {
@@ -85,9 +69,12 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
}, },
value: query.compilation, value: query.compilation,
}, },
]; ],
[t, query.favorite, query.compilation, setFavorite, setCompilation],
);
const toggleFilters = [ const toggleFilters = useMemo(
() => [
{ {
label: t('filter.isRated', { postProcess: 'sentenceCase' }), label: t('filter.isRated', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (e: ChangeEvent<HTMLInputElement>) => {
@@ -104,13 +91,35 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
}, },
value: query.recentlyPlayed, value: query.recentlyPlayed,
}, },
]; ],
[t, query.hasRating, query.recentlyPlayed, setHasRating, setRecentlyPlayed],
);
const handleYearFilter = debounce((e: number | string) => { const handleYearFilter = useMemo(
const year = e === '' ? undefined : (e as number); () => (e: number | string) => {
setMinYear(year ?? null); // Handle empty string, null, undefined, or invalid numbers as clearing
setMaxYear(year ?? null);
}, 500); if (e === '' || e === null || e === undefined) {
console.log('clearing year filters');
setMinYear(null);
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year > 0) {
console.log('setting year filters', year);
setMinYear(year);
setMaxYear(year);
} else {
console.log('clearing year filters', year);
setMinYear(null);
setMaxYear(null);
}
},
[setMinYear, setMaxYear],
);
const albumArtistListQuery = useQuery( const albumArtistListQuery = useQuery(
artistsQueries.albumArtistList({ artistsQueries.albumArtistList({
@@ -136,26 +145,15 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
})); }));
}, [albumArtistListQuery.data?.items]); }, [albumArtistListQuery.data?.items]);
const handleTagFilter = debounce((tag: string, e: null | string) => {
setCustom((prev) => ({
...prev,
[tag]: e || undefined,
}));
}, 250);
const hasBFR = hasFeature(server, ServerFeature.BFR);
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{yesNoUndefinedFilters.map((filter) => ( {yesNoUndefinedFilters.map((filter) => (
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect <YesNoSelect
key={`nd-filter-${filter.label}`}
label={filter.label}
onChange={filter.onChange} onChange={filter.onChange}
size="xs"
value={filter.value ?? undefined} value={filter.value ?? undefined}
/> />
</Group>
))} ))}
{toggleFilters.map((filter) => ( {toggleFilters.map((filter) => (
<Group justify="space-between" key={`nd-filter-${filter.label}`}> <Group justify="space-between" key={`nd-filter-${filter.label}`}>
@@ -164,37 +162,22 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
</Group> </Group>
))} ))}
<Divider my="0.5rem" /> <Divider my="0.5rem" />
<Group grow>
<NumberInput <NumberInput
defaultValue={query.minYear ?? undefined} defaultValue={query.minYear ?? undefined}
hideControls={false} hideControls={false}
label={t('common.year', { postProcess: 'titleCase' })} label={t('common.year', { postProcess: 'titleCase' })}
max={5000} max={5000}
min={0} min={0}
onChange={(e) => handleYearFilter(e)} onBlur={(e) => handleYearFilter(e.currentTarget.value)}
/> />
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreId ? query.genreId[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={(e) => (e ? setGenreId([e]) : undefined)}
searchable
/>
</Group>
{hasBFR && (
<Group grow>
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable
data={genreList} data={genreList}
defaultValue={query.genreId} defaultValue={query.genreIds}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })} label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e ? setGenreId(e) : undefined)} onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
searchable searchable
/> />
</Group>
)}
<Group grow>
<SelectWithInvalidData <SelectWithInvalidData
clearable clearable
data={selectableAlbumArtists} data={selectableAlbumArtists}
@@ -206,25 +189,126 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined} rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable searchable
/> />
</Group> <TagFilters />
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.length > 0 &&
tagsQuery.data.enumTags.map((tag) => (
<Group grow key={tag.name}>
<SelectWithInvalidData
clearable
data={tag.options}
defaultValue={query._custom?.[tag.name] as string | undefined}
label={
NDSongQueryFields.find((i) => i.value === tag.name)?.label ||
tag.name
}
onChange={(value) => handleTagFilter(tag.name, value)}
searchable
width={150}
/>
</Group>
))}
</Stack> </Stack>
); );
}; };
interface TagFilterItemProps {
label: string;
onChange: (value: null | string) => void;
options: string[];
tagValue: string;
value: string | undefined;
}
const TagFilterItem = memo(
({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
return (
<SelectWithInvalidData
clearable
data={options}
defaultValue={value}
key={tagValue}
label={label}
limit={100}
onChange={onChange}
searchable
/>
);
},
(prevProps, nextProps) => {
// Only re-render if the specific tag's value or options change
// We don't compare onChange since it's a stable wrapper around handleTagFilter
// and handleTagFilter itself is memoized and stable
return (
prevProps.tagValue === nextProps.tagValue &&
prevProps.label === nextProps.label &&
prevProps.value === nextProps.value &&
prevProps.options === nextProps.options
);
},
);
TagFilterItem.displayName = 'TagFilterItem';
const TagFilters = () => {
const { query, setCustom } = useAlbumListFilters();
const serverId = useCurrentServerId();
const tagsQuery = useQuery(
sharedQueries.tags({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: {
type: LibraryItem.ALBUM,
},
serverId,
}),
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string) => {
setCustom((prev) => {
if (!prev) {
return e ? { [tag]: e } : null;
}
if (e === null) {
const rest = Object.fromEntries(
Object.entries(prev).filter(([key]) => key !== tag),
);
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
[tag]: e,
};
});
},
[setCustom],
);
const tags = useMemo(() => {
return (
tagsQuery.data?.enumTags?.map((tag) => ({
label: titleCase(tag.name),
options: tag.options,
value: tag.name,
})) || []
);
}, [tagsQuery.data?.enumTags]);
// Create stable onChange handlers for each tag using useMemo
const tagHandlers = useMemo(() => {
const handlers = new Map<string, (value: null | string) => void>();
tags.forEach((tag) => {
handlers.set(tag.value, (value: null | string) => handleTagFilter(tag.value, value));
});
return handlers;
}, [tags, handleTagFilter]);
if (tagsQuery.isLoading) {
return <Spinner container />;
}
return (
<>
{tags.map((tag) => (
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={tagHandlers.get(tag.value)!}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | undefined}
/>
))}
</>
);
};
@@ -6,7 +6,7 @@ import {
parseAsString, parseAsString,
useQueryState, useQueryState,
} from 'nuqs'; } from 'nuqs';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter';
@@ -86,7 +86,8 @@ export const useAlbumListFilters = () => {
setSortOrder, setSortOrder,
]); ]);
const query = { const query = useMemo(
() => ({
[FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined, [FILTER_KEYS.ALBUM._CUSTOM]: custom ?? undefined,
[FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined, [FILTER_KEYS.ALBUM.ARTIST_IDS]: albumArtist ?? undefined,
[FILTER_KEYS.ALBUM.COMPILATION]: compilation ?? undefined, [FILTER_KEYS.ALBUM.COMPILATION]: compilation ?? undefined,
@@ -99,7 +100,22 @@ export const useAlbumListFilters = () => {
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined,
[FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined,
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined,
}; }),
[
custom,
albumArtist,
compilation,
favorite,
genreId,
hasRating,
maxYear,
minYear,
recentlyPlayed,
searchTerm,
sortBy,
sortOrder,
],
);
return { return {
clear, clear,
@@ -5,6 +5,7 @@ import { ListContext } from '/@/renderer/context/list-context';
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import { ListWithSidebarContainer } from '/@/renderer/features/shared/components/list-with-sidebar-container';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AlbumListQuery } from '/@/shared/types/domain-types'; import { AlbumListQuery } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -28,10 +29,19 @@ const AlbumListRoute = () => {
const [itemCount, setItemCount] = useState<number | undefined>(undefined); const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const customFilters: Partial<AlbumListQuery> = useMemo(() => { const customFilters: Partial<AlbumListQuery> = useMemo(() => {
if (albumArtistId) {
return { return {
artistIds: albumArtistId ? [albumArtistId] : undefined, artistIds: [albumArtistId],
genreIds: genreId ? [genreId] : undefined,
}; };
}
if (genreId) {
return {
genreIds: [genreId],
};
}
return {};
}, [albumArtistId, genreId]); }, [albumArtistId, genreId]);
const providerValue = useMemo(() => { const providerValue = useMemo(() => {
@@ -48,7 +58,9 @@ const AlbumListRoute = () => {
<AnimatedPage> <AnimatedPage>
<ListContext.Provider value={providerValue}> <ListContext.Provider value={providerValue}>
<AlbumListHeader /> <AlbumListHeader />
<ListWithSidebarContainer>
<AlbumListContent /> <AlbumListContent />
</ListWithSidebarContainer>
</ListContext.Provider> </ListContext.Provider>
</AnimatedPage> </AnimatedPage>
); );
@@ -1,6 +1,6 @@
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -24,7 +24,7 @@ export const GenreListHeaderFilters = () => {
defaultSortOrder={SortOrder.ASC} defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.GENRE} listKey={ItemListKey.GENRE}
/> />
<ListFilters itemType={LibraryItem.GENRE} /> <ListFiltersModal itemType={LibraryItem.GENRE} />
<ListRefreshButton listKey={ItemListKey.GENRE} /> <ListRefreshButton listKey={ItemListKey.GENRE} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form'; import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -39,7 +39,7 @@ export const PlaylistListHeaderFilters = () => {
defaultSortOrder={SortOrder.ASC} defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.PLAYLIST} listKey={ItemListKey.PLAYLIST}
/> />
<ListFilters itemType={LibraryItem.PLAYLIST} /> <ListFiltersModal itemType={LibraryItem.PLAYLIST} />
<ListRefreshButton listKey={ItemListKey.PLAYLIST} /> <ListRefreshButton listKey={ItemListKey.PLAYLIST} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -17,7 +17,7 @@ interface ListFiltersProps {
itemType: LibraryItem; itemType: LibraryItem;
} }
export const ListFilters = ({ isActive, itemType }: ListFiltersProps) => { export const ListFiltersModal = ({ isActive, itemType }: ListFiltersProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
@@ -41,6 +41,14 @@ export const ListFilters = ({ isActive, itemType }: ListFiltersProps) => {
); );
}; };
export const ListFilters = ({ itemType }: ListFiltersProps) => {
const server = useCurrentServer();
const serverType = server.type;
const FilterComponent = FILTERS[serverType][itemType];
return <FilterComponent />;
};
const FILTERS = { const FILTERS = {
[ServerType.JELLYFIN]: { [ServerType.JELLYFIN]: {
[LibraryItem.ALBUM]: JellyfinAlbumFilters, [LibraryItem.ALBUM]: JellyfinAlbumFilters,
@@ -0,0 +1,31 @@
.container {
position: relative;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
container-type: inline-size;
overflow: hidden;
}
.sidebar-container {
position: relative;
flex-shrink: 0;
width: 300px;
min-width: 300px;
max-width: 300px;
height: 100%;
overflow: hidden;
border-right: 1px solid var(--theme-colors-border);
}
.content-container {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
overflow: hidden;
}
@@ -0,0 +1,100 @@
import { motion } from 'motion/react';
import { createContext, ReactNode, useContext, useMemo, useRef } from 'react';
import styles from './list-with-sidebar-container.module.css';
import { useContainerQuery } from '/@/renderer/hooks';
import { animationProps } from '/@/shared/components/animations/animation-props';
import { Portal } from '/@/shared/components/portal/portal';
interface ListWithSidebarContainerContextValue {
showSidebar: boolean;
sidebarRef: React.RefObject<HTMLDivElement | null>;
}
const ListWithSidebarContainerContext = createContext<ListWithSidebarContainerContextValue | null>(
null,
);
interface ListWithSidebarContainerProps {
children: ReactNode;
sidebarBreakpoint?: number;
}
interface SidebarPortalProps {
children: ReactNode;
}
interface SidebarProps {
children: ReactNode;
}
function Sidebar({ children }: SidebarProps) {
const context = useContext(ListWithSidebarContainerContext);
if (!context) {
throw new Error('Sidebar must be used within ResponsiveAnimatedPage');
}
if (!context.showSidebar || !context.sidebarRef?.current) {
return null;
}
return (
<Portal target={context.sidebarRef.current}>
<motion.div {...animationProps.slideInLeft} style={{ height: '100%', width: '100%' }}>
{children}
</motion.div>
</Portal>
);
}
function SidebarPortal({ children }: SidebarPortalProps) {
const context = useContext(ListWithSidebarContainerContext);
if (!context) {
throw new Error('SidebarPortal must be used within ResponsiveAnimatedPage');
}
if (!context.showSidebar || !context.sidebarRef?.current) {
return null;
}
return <Portal target={context.sidebarRef.current}>{children}</Portal>;
}
export const ListWithSidebarContainer = ({
children,
sidebarBreakpoint,
}: ListWithSidebarContainerProps) => {
const sidebarRef = useRef<HTMLDivElement>(null);
const { isLg, ref: containerQueryRef } = useContainerQuery({
lg: sidebarBreakpoint,
});
const showSidebar = isLg;
const contextValue = useMemo(
() => ({
showSidebar,
sidebarRef,
}),
[showSidebar],
);
return (
<ListWithSidebarContainerContext.Provider value={contextValue}>
<div className={styles.container} ref={containerQueryRef}>
<div
className={styles.sidebarContainer}
ref={sidebarRef}
style={{ display: showSidebar ? 'block' : 'none' }}
/>
<div className={styles.contentContainer}>{children}</div>
</div>
</ListWithSidebarContainerContext.Provider>
);
};
ListWithSidebarContainer.Sidebar = Sidebar;
ListWithSidebarContainer.SidebarPortal = SidebarPortal;
+1 -1
View File
@@ -40,7 +40,7 @@ enum AlbumFilterKeys {
ARTIST_IDS = 'artistIds', ARTIST_IDS = 'artistIds',
COMPILATION = 'compilation', COMPILATION = 'compilation',
FAVORITE = 'favorite', FAVORITE = 'favorite',
GENRE_ID = 'genreId', GENRE_ID = 'genreIds',
HAS_RATING = 'hasRating', HAS_RATING = 'hasRating',
MAX_YEAR = 'maxYear', MAX_YEAR = 'maxYear',
MIN_YEAR = 'minYear', MIN_YEAR = 'minYear',
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; import { ListFiltersModal } from '/@/renderer/features/shared/components/list-filters';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
@@ -65,7 +65,7 @@ export const SongListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarget
defaultSortOrder={SortOrder.ASC} defaultSortOrder={SortOrder.ASC}
listKey={ItemListKey.SONG} listKey={ItemListKey.SONG}
/> />
<ListFilters itemType={LibraryItem.SONG} /> <ListFiltersModal itemType={LibraryItem.SONG} />
<ListRefreshButton listKey={ItemListKey.SONG} /> <ListRefreshButton listKey={ItemListKey.SONG} />
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
+2 -1
View File
@@ -31,7 +31,8 @@ export const Select = ({
section: styles.section, section: styles.section,
...classNames, ...classNames,
}} }}
clearable={false} clearable={clearable}
spellCheck={false}
style={{ maxWidth, width }} style={{ maxWidth, width }}
variant={variant} variant={variant}
withCheckIcon={false} withCheckIcon={false}