fix list filters

This commit is contained in:
jeffvli
2025-12-02 00:11:42 -08:00
parent 4abfbd1973
commit aff7a61bca
26 changed files with 1022 additions and 565 deletions
@@ -3,27 +3,26 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { SongListFilter, useCurrentServerId } from '/@/renderer/store';
import { useCurrentServerId } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem } from '/@/shared/types/domain-types';
interface JellyfinSongFiltersProps {
customFilters?: Partial<SongListFilter>;
}
export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) => {
export const JellyfinSongFilters = () => {
const serverId = useCurrentServerId();
const { t } = useTranslation();
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
const { customFilters } = useListContext();
const isGenrePage = customFilters?.genreIds !== undefined;
// Despite the fact that getTags returns genres, it only returns genre names.
@@ -103,23 +102,27 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
[setMaxYear],
);
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
const handleGenresFilter = useMemo(
() => (e: string[] | undefined) => {
setCustom((prev) => {
const current = prev ?? {};
if (!e || e.length === 0) {
// Remove GenreIds and IncludeItemTypes if genres are cleared
const rest = { ...prev };
const rest = { ...current };
delete rest.GenreIds;
delete rest.IncludeItemTypes;
// Keep jellyfin-specific properties
// Return null if object is empty, otherwise return the rest
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
...current,
GenreIds: e.join(','),
IncludeItemTypes: 'Audio',
...prev?.jellyfin,
};
});
},
@@ -128,38 +131,22 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
const handleTagFilter = useMemo(
() => (e: string[] | undefined) => {
setCustom((prev) => {
if (!e || e.length === 0) {
// Remove Tags if cleared
const rest = { ...prev };
delete rest.Tags;
// Keep IncludeItemTypes and jellyfin-specific properties
if (rest.IncludeItemTypes) {
return rest;
}
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
IncludeItemTypes: 'Audio',
Tags: e.join('|'),
...prev?.jellyfin,
};
});
setCustom({ Tags: e?.join('|') ?? null });
},
[setCustom],
);
return (
<Stack p="0.8rem">
<Stack p="md">
{yesNoFilters.map((filter) => (
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
</Group>
<YesNoSelect
defaultValue={filter.value ? filter.value.toString() : undefined}
key={`jf-filter-${filter.label}`}
label={filter.label}
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
/>
))}
<Divider my="0.5rem" />
<Divider my="md" />
<Group grow>
<NumberInput
defaultValue={query.minYear ?? undefined}
@@ -167,7 +154,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
onChange={(e) => debouncedHandleMinYearFilter(e)}
required={!!query.minYear}
/>
<NumberInput
@@ -176,35 +163,29 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
onChange={(e) => debouncedHandleMaxYearFilter(e)}
required={!!query.minYear}
/>
</Group>
{!isGenrePage && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={selectedGenres}
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
onChange={(e) => handleGenresFilter(e)}
searchable
width={250}
/>
</Group>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={selectedGenres}
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
onChange={(e) => handleGenresFilter(e)}
searchable
/>
)}
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={tagsQuery.data.boolTags}
defaultValue={selectedTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={(e) => handleTagFilter(e)}
searchable
width={250}
/>
</Group>
<MultiSelectWithInvalidData
clearable
data={tagsQuery.data.boolTags}
defaultValue={selectedTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={(e) => handleTagFilter(e)}
searchable
/>
)}
</Stack>
);
@@ -1,11 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { memo, useMemo } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
MultiSelectWithInvalidData,
SelectWithInvalidData,
} from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
@@ -13,16 +14,19 @@ import { useCurrentServerId } from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
import { Divider } from '/@/shared/components/divider/divider';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { LibraryItem } from '/@/shared/types/domain-types';
export const NavidromeSongFilters = () => {
const { t } = useTranslation();
const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters();
const { customFilters } = useListContext();
const isGenrePage = customFilters?.genreIds !== undefined;
const genreListQuery = useGenreList();
const genreList = useMemo(() => {
@@ -69,33 +73,39 @@ export const NavidromeSongFilters = () => {
[setMinYear, setMaxYear],
);
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
return (
<Stack p="0.8rem">
<Stack p="md">
{yesNoUndefinedFilters.map((filter) => (
<YesNoSelect
clearable
defaultValue={filter.value ? filter.value.toString() : undefined}
key={`nd-filter-${filter.label}`}
label={filter.label}
onChange={filter.onChange}
value={filter.value ?? undefined}
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
/>
))}
<Divider my="0.5rem" />
<Divider my="md" />
<NumberInput
defaultValue={query.minYear ?? undefined}
hideControls={false}
label={t('common.year', { postProcess: 'titleCase' })}
max={5000}
min={0}
onBlur={(e) => handleYearFilter(e.currentTarget.value)}
/>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreId}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
searchable
onChange={(e) => debouncedHandleYearFilter(e)}
/>
{!isGenrePage && (
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreIds || []}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
searchable
/>
)}
<Divider my="md" />
<TagFilters />
</Stack>
);
@@ -104,38 +114,34 @@ export const NavidromeSongFilters = () => {
interface TagFilterItemProps {
label: string;
onChange: (value: null | string) => void;
options: string[];
options: Array<{ id: string; name: 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
);
},
);
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
const selectData = useMemo(
() =>
options.map((option) => ({
label: option.name,
value: option.id,
})),
[options],
);
return (
<SelectWithInvalidData
clearable
data={selectData}
defaultValue={value}
key={tagValue}
label={label}
limit={100}
onChange={onChange}
searchable
/>
);
};
TagFilterItem.displayName = 'TagFilterItem';
@@ -144,65 +150,36 @@ const TagFilters = () => {
const serverId = useCurrentServerId();
const tagsQuery = useQuery(
const tagsQuery = useSuspenseQuery(
sharedQueries.tags({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: {
type: LibraryItem.SONG,
},
query: { type: LibraryItem.SONG },
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({ [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]);
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
[];
// 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]);
for (const tag of tagsQuery.data?.enumTags || []) {
if (!tagsQuery.data?.excluded.song.includes(tag.name)) {
results.push({
label: titleCase(tag.name),
options: tag.options,
value: tag.name,
});
}
}
if (tagsQuery.isLoading) {
return <Spinner container />;
}
return results;
}, [tagsQuery.data]);
return (
<>
@@ -210,7 +187,7 @@ const TagFilters = () => {
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={tagHandlers.get(tag.value)!}
onChange={(e) => handleTagFilter(tag.value, e)}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | undefined}
@@ -2,23 +2,21 @@ import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { SongListFilter } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
interface SubsonicSongFiltersProps {
customFilters?: Partial<SongListFilter>;
}
export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) => {
export const SubsonicSongFilters = () => {
const { t } = useTranslation();
const { query, setFavorite, setGenreId } = useSongListFilters();
const { customFilters } = useListContext();
const isGenrePage = customFilters?.genreIds !== undefined;
const genreListQuery = useGenreList();
@@ -53,27 +51,26 @@ export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps)
);
return (
<Stack p="0.8rem">
<Stack p="md">
{toggleFilters.map((filter) => (
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<Switch checked={filter.value ?? false} onChange={filter.onChange} />
<Switch defaultChecked={filter.value ?? false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
{!isGenrePage && (
{!isGenrePage && (
<>
<Divider my="md" />
<SelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreId ? query.genreId[0] : undefined}
defaultValue={query.genreIds ? query.genreIds[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
searchable
width={150}
/>
)}
</Group>
</>
)}
</Stack>
);
};
@@ -1,17 +1,18 @@
import {
parseAsArrayOf,
parseAsBoolean,
parseAsInteger,
parseAsJson,
parseAsString,
useQueryState,
} from 'nuqs';
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
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 { customFiltersSchema, FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import {
parseArrayParam,
parseBooleanParam,
parseCustomFiltersParam,
parseIntParam,
setJsonSearchParam,
setSearchParam,
} from '/@/renderer/utils/query-params';
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -25,30 +26,123 @@ export const useSongListFilters = () => {
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
const [albumIds, setAlbumIds] = useQueryState(
FILTER_KEYS.SONG.ALBUM_IDS,
parseAsArrayOf(parseAsString),
const [searchParams, setSearchParams] = useSearchParams();
const albumIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
[searchParams],
);
const [genreId, setGenreId] = useQueryState(
FILTER_KEYS.SONG.GENRE_ID,
parseAsArrayOf(parseAsString),
const genreId = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
[searchParams],
);
const [artistIds, setArtistIds] = useQueryState(
FILTER_KEYS.SONG.ARTIST_IDS,
parseAsArrayOf(parseAsString),
const artistIds = useMemo(
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS),
[searchParams],
);
const [minYear, setMinYear] = useQueryState(FILTER_KEYS.SONG.MIN_YEAR, parseAsInteger);
const minYear = useMemo(
() => parseIntParam(searchParams, FILTER_KEYS.SONG.MIN_YEAR),
[searchParams],
);
const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.SONG.MAX_YEAR, parseAsInteger);
const maxYear = useMemo(
() => parseIntParam(searchParams, FILTER_KEYS.SONG.MAX_YEAR),
[searchParams],
);
const [favorite, setFavorite] = useQueryState(FILTER_KEYS.SONG.FAVORITE, parseAsBoolean);
const favorite = useMemo(
() => parseBooleanParam(searchParams, FILTER_KEYS.SONG.FAVORITE),
[searchParams],
);
const [custom, setCustom] = useQueryState(
FILTER_KEYS.SONG._CUSTOM,
parseAsJson(customFiltersSchema),
const custom = useMemo(
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
[searchParams],
);
const setAlbumIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ALBUM_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
const setGenreId = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.GENRE_ID, value), {
replace: true,
});
},
[setSearchParams],
);
const setArtistIds = useCallback(
(value: null | string[]) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.ARTIST_IDS, value), {
replace: true,
});
},
[setSearchParams],
);
const setMinYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MIN_YEAR, value), {
replace: true,
});
},
[setSearchParams],
);
const setMaxYear = useCallback(
(value: null | number) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.MAX_YEAR, value), {
replace: true,
});
},
[setSearchParams],
);
const setFavorite = useCallback(
(value: boolean | null) => {
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.FAVORITE, value), {
replace: true,
});
},
[setSearchParams],
);
const setCustom = useCallback(
(
value:
| ((prev: null | Record<string, any>) => null | Record<string, any>)
| null
| Record<string, any>,
) => {
setSearchParams(
(prev) => {
const currentCustom = parseCustomFiltersParam(prev, FILTER_KEYS.SONG._CUSTOM);
let newValue =
typeof value === 'function' ? value(currentCustom ?? null) : value;
// Convert empty objects to null to clear them from URL
if (
newValue &&
typeof newValue === 'object' &&
Object.keys(newValue).length === 0
) {
newValue = null;
}
return setJsonSearchParam(prev, FILTER_KEYS.SONG._CUSTOM, newValue);
},
{ replace: true },
);
},
[setSearchParams],
);
const clear = useCallback(() => {