mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
fix list filters
This commit is contained in:
@@ -72,7 +72,6 @@
|
|||||||
"@mantine/hooks": "^8.3.8",
|
"@mantine/hooks": "^8.3.8",
|
||||||
"@mantine/modals": "^8.3.8",
|
"@mantine/modals": "^8.3.8",
|
||||||
"@mantine/notifications": "^8.3.8",
|
"@mantine/notifications": "^8.3.8",
|
||||||
"@offlegacy/nuqs-hash-router": "^0.1.1",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"@tanstack/react-query-devtools": "^5.90.2",
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
|
|||||||
Generated
-14
@@ -44,9 +44,6 @@ importers:
|
|||||||
'@mantine/notifications':
|
'@mantine/notifications':
|
||||||
specifier: ^8.3.8
|
specifier: ^8.3.8
|
||||||
version: 8.3.8(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.1.0))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.3.8(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 8.3.8(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.1.0))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@mantine/hooks@8.3.8(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@offlegacy/nuqs-hash-router':
|
|
||||||
specifier: ^0.1.1
|
|
||||||
version: 0.1.1(nuqs@2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
|
||||||
'@radix-ui/react-context-menu':
|
'@radix-ui/react-context-menu':
|
||||||
specifier: ^2.2.16
|
specifier: ^2.2.16
|
||||||
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -1398,12 +1395,6 @@ packages:
|
|||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
deprecated: This functionality has been moved to @npmcli/fs
|
deprecated: This functionality has been moved to @npmcli/fs
|
||||||
|
|
||||||
'@offlegacy/nuqs-hash-router@0.1.1':
|
|
||||||
resolution: {integrity: sha512-dRTyovVxKBjRQrFU3qR7zBW/AvnVtYtBXWqIrkhIJXXLwytT05dIAz3dKxhXN9WvLuGdbzOP66p1ML2WR60CSQ==}
|
|
||||||
peerDependencies:
|
|
||||||
nuqs: '2'
|
|
||||||
react: '>=18'
|
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -7111,11 +7102,6 @@ snapshots:
|
|||||||
mkdirp: 1.0.4
|
mkdirp: 1.0.4
|
||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
|
|
||||||
'@offlegacy/nuqs-hash-router@0.1.1(nuqs@2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0))(react@19.1.0)':
|
|
||||||
dependencies:
|
|
||||||
nuqs: 2.7.1(react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@7.9.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
|
||||||
react: 19.1.0
|
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
||||||
return { boolTags: undefined, enumTags: undefined };
|
return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).getFilterList({
|
const res = await jfApiClient(apiClientProps).getFilterList({
|
||||||
@@ -876,6 +876,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
boolTags: res.body.Tags?.sort((a, b) =>
|
boolTags: res.body.Tags?.sort((a, b) =>
|
||||||
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
||||||
),
|
),
|
||||||
|
excluded: { album: [], song: [] },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getTopSongs: async (args) => {
|
getTopSongs: async (args) => {
|
||||||
|
|||||||
@@ -45,7 +45,31 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
|
|||||||
'remixer',
|
'remixer',
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
|
// Tags that are irrelevant or non-functional as filters
|
||||||
|
const EXCLUDED_TAGS = new Set<string>([
|
||||||
|
'genre', // Duplicate of genre filter
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXCLUDED_ALBUM_TAGS = new Set<string>([
|
||||||
|
'asin',
|
||||||
|
'barcode',
|
||||||
|
'copyright',
|
||||||
|
'disctotal',
|
||||||
|
'encodedby',
|
||||||
|
'isrc',
|
||||||
|
'key',
|
||||||
|
'language',
|
||||||
|
'musicbrainz_workid',
|
||||||
|
'script',
|
||||||
|
'tracktotal',
|
||||||
|
'website',
|
||||||
|
'work',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXCLUDED_SONG_TAGS = new Set<string>([]);
|
||||||
|
|
||||||
|
// Tags that use IDs as values as opposed to the tag value
|
||||||
|
const ID_TAGS = new Set<string>(['albumversion', 'mood']);
|
||||||
|
|
||||||
const excludeMissing = (server?: null | ServerListItemWithCredential) => {
|
const excludeMissing = (server?: null | ServerListItemWithCredential) => {
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -297,8 +321,10 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
artist_id: query.artistIds?.[0],
|
artist_id: query.artistIds?.[0],
|
||||||
compilation: query.compilation,
|
compilation: query.compilation,
|
||||||
genre_id: genres,
|
genre_id: genres,
|
||||||
|
has_rating: query.hasRating,
|
||||||
library_id: getLibraryId(query.musicFolderId),
|
library_id: getLibraryId(query.musicFolderId),
|
||||||
name: query.searchTerm,
|
name: query.searchTerm,
|
||||||
|
recently_played: query.isRecentlyPlayed,
|
||||||
year: query.maxYear || query.minYear,
|
year: query.maxYear || query.minYear,
|
||||||
...query._custom,
|
...query._custom,
|
||||||
starred: query.favorite,
|
starred: query.favorite,
|
||||||
@@ -615,7 +641,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
||||||
return { boolTags: undefined, enumTags: undefined };
|
return { boolTags: undefined, enumTags: undefined, excluded: { album: [], song: [] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await ndApiClient(apiClientProps).getTags();
|
const res = await ndApiClient(apiClientProps).getTags();
|
||||||
@@ -624,30 +650,47 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
throw new Error('failed to get tags');
|
throw new Error('failed to get tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsToValues = new Map<string, string[]>();
|
const tagsToValues = new Map<string, { id: string; name: string }[]>();
|
||||||
|
|
||||||
for (const tag of res.body.data) {
|
for (const tag of res.body.data) {
|
||||||
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
if (!EXCLUDED_TAGS.has(tag.tagName)) {
|
||||||
if (tagsToValues.has(tag.tagName)) {
|
if (tagsToValues.has(tag.tagName)) {
|
||||||
tagsToValues.get(tag.tagName)!.push(tag.tagValue);
|
tagsToValues.get(tag.tagName)!.push({
|
||||||
|
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
||||||
|
name: tag.tagValue,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
tagsToValues.set(tag.tagName, [tag.tagValue]);
|
tagsToValues.set(tag.tagName, [
|
||||||
|
{
|
||||||
|
id: ID_TAGS.has(tag.tagName) ? tag.id : tag.tagValue,
|
||||||
|
name: tag.tagValue,
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const enumTags = Array.from(tagsToValues)
|
||||||
boolTags: undefined,
|
|
||||||
enumTags: Array.from(tagsToValues)
|
|
||||||
.map((data) => ({
|
.map((data) => ({
|
||||||
name: data[0],
|
name: data[0],
|
||||||
options: data[1].sort((a, b) =>
|
options: data[1]
|
||||||
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||||
),
|
)
|
||||||
|
.map((option) => ({ id: option.id, name: option.name })),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()));
|
||||||
|
|
||||||
|
const excludedAlbumTags = Array.from(EXCLUDED_ALBUM_TAGS.values());
|
||||||
|
const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());
|
||||||
|
|
||||||
|
return {
|
||||||
|
boolTags: undefined,
|
||||||
|
enumTags,
|
||||||
|
excluded: {
|
||||||
|
album: excludedAlbumTags,
|
||||||
|
song: excludedSongTags,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getTopSongs: SubsonicController.getTopSongs,
|
getTopSongs: SubsonicController.getTopSongs,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
|
|
||||||
interface UseItemListScrollPersistProps {
|
interface UseItemListScrollPersistProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
||||||
const [scrollOffset, setScrollOffset] = useQueryState('scrollOffset', parseAsInteger);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]);
|
||||||
|
|
||||||
const handleOnScrollEnd = (offset: number) => {
|
const handleOnScrollEnd = (offset: number) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
setScrollOffset(offset);
|
setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return { handleOnScrollEnd, scrollOffset };
|
return { handleOnScrollEnd, scrollOffset };
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
|
|
||||||
export const useItemListPagination = () => {
|
export const useItemListPagination = () => {
|
||||||
const [currentPage, setCurrentPage] = useQueryState(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
'currentPage',
|
|
||||||
parseAsInteger.withDefault(0),
|
const currentPage = useMemo(() => {
|
||||||
);
|
const value = parseIntParam(searchParams, 'currentPage');
|
||||||
|
return value ?? 0;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const onChange = (index: number) => {
|
const onChange = (index: number) => {
|
||||||
setCurrentPage(index);
|
setSearchParams((prev) => setSearchParam(prev, 'currentPage', index), { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return { currentPage, onChange };
|
return { currentPage, onChange };
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
|
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 { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
import { genresQueries } 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 { AlbumListFilter, useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
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 { SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
||||||
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
import {
|
import {
|
||||||
AlbumArtistListSort,
|
AlbumArtistListSort,
|
||||||
GenreListSort,
|
GenreListSort,
|
||||||
@@ -22,15 +24,17 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
interface JellyfinAlbumFiltersProps {
|
interface JellyfinAlbumFiltersProps {
|
||||||
customFilters?: Partial<AlbumListFilter>;
|
|
||||||
disableArtistFilter?: boolean;
|
disableArtistFilter?: boolean;
|
||||||
onFilterChange: (filters: AlbumListFilter) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFiltersProps) => {
|
export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFiltersProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
query,
|
query,
|
||||||
setAlbumArtist,
|
setAlbumArtist,
|
||||||
@@ -180,46 +184,25 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
|
|
||||||
const handleTagFilter = useMemo(
|
const handleTagFilter = useMemo(
|
||||||
() => (e: string[] | undefined) => {
|
() => (e: string[] | undefined) => {
|
||||||
setCustom((prev) => {
|
setCustom({ Tags: e?.join('|') ?? null });
|
||||||
if (!prev) {
|
|
||||||
return e && e.length > 0 ? { [e.join('|')]: e.join('|') } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!e || e.length === 0) {
|
|
||||||
// Remove all tag-related properties (they use '|' joined keys)
|
|
||||||
const rest = Object.fromEntries(
|
|
||||||
Object.entries(prev).filter(([key]) => !key.includes('|')),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Object.keys(rest).length === 0 ? null : rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old tag entries and add new one
|
|
||||||
const rest = Object.fromEntries(
|
|
||||||
Object.entries(prev).filter(([key]) => !key.includes('|')),
|
|
||||||
);
|
|
||||||
const tagKey = e.join('|');
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
[tagKey]: tagKey,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[setCustom],
|
[setCustom],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
|
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{yesNoFilter.map((filter) => (
|
{yesNoFilter.map((filter) => (
|
||||||
<YesNoSelect
|
<YesNoSelect
|
||||||
|
defaultValue={filter.value ? filter.value.toString() : undefined}
|
||||||
key={`jf-filter-${filter.label}`}
|
key={`jf-filter-${filter.label}`}
|
||||||
label={filter.label}
|
label={filter.label}
|
||||||
onChange={filter.onChange}
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
value={filter.value ?? undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
@@ -227,7 +210,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||||
max={2300}
|
max={2300}
|
||||||
min={1700}
|
min={1700}
|
||||||
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMinYearFilter(e)}
|
||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -236,23 +219,24 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||||
max={2300}
|
max={2300}
|
||||||
min={1700}
|
min={1700}
|
||||||
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMaxYearFilter(e)}
|
||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isGenrePage && (
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
defaultValue={query.genreIds ?? undefined}
|
defaultValue={query.genreIds || []}
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => handleGenresFilter(e)}
|
onChange={handleGenresFilter}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={selectableAlbumArtists}
|
data={selectableAlbumArtists}
|
||||||
defaultValue={query.artistIds ?? undefined}
|
defaultValue={query.artistIds || []}
|
||||||
disabled={disableArtistFilter}
|
disabled={disableArtistFilter}
|
||||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
limit={300}
|
limit={300}
|
||||||
@@ -264,7 +248,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
|
|||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={tagsQuery.data.boolTags}
|
data={tagsQuery.data.boolTags}
|
||||||
defaultValue={query._custom?.[tagsQuery.data.boolTags.join('|')] ?? undefined}
|
defaultValue={query._custom?.[tagsQuery.data.boolTags.join('|')] || []}
|
||||||
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||||
onChange={handleTagFilter}
|
onChange={handleTagFilter}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { ChangeEvent, memo, useMemo } from 'react';
|
import { ChangeEvent, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MultiSelectWithInvalidData,
|
MultiSelectWithInvalidData,
|
||||||
SelectWithInvalidData,
|
SelectWithInvalidData,
|
||||||
} from '/@/renderer/components/select-with-invalid-data';
|
} from '/@/renderer/components/select-with-invalid-data';
|
||||||
|
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 { 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';
|
||||||
@@ -15,11 +16,12 @@ import { titleCase } from '/@/renderer/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 { Spinner, SpinnerIcon } from '/@/shared/components/spinner/spinner';
|
import { 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 { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
interface NavidromeAlbumFiltersProps {
|
interface NavidromeAlbumFiltersProps {
|
||||||
@@ -31,6 +33,10 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const serverId = server.id;
|
const serverId = server.id;
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
query,
|
query,
|
||||||
setAlbumArtist,
|
setAlbumArtist,
|
||||||
@@ -89,10 +95,10 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
const recentlyPlayed = e.currentTarget.checked ? true : undefined;
|
const recentlyPlayed = e.currentTarget.checked ? true : undefined;
|
||||||
setRecentlyPlayed(recentlyPlayed ?? null);
|
setRecentlyPlayed(recentlyPlayed ?? null);
|
||||||
},
|
},
|
||||||
value: query.recentlyPlayed,
|
value: query.isRecentlyPlayed,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, query.hasRating, query.recentlyPlayed, setHasRating, setRecentlyPlayed],
|
[t, query.hasRating, query.isRecentlyPlayed, setHasRating, setRecentlyPlayed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleYearFilter = useMemo(
|
const handleYearFilter = useMemo(
|
||||||
@@ -142,43 +148,48 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
}));
|
}));
|
||||||
}, [albumArtistListQuery.data?.items]);
|
}, [albumArtistListQuery.data?.items]);
|
||||||
|
|
||||||
|
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{yesNoUndefinedFilters.map((filter) => (
|
{yesNoUndefinedFilters.map((filter) => (
|
||||||
<YesNoSelect
|
<YesNoSelect
|
||||||
|
clearable
|
||||||
|
defaultValue={filter.value ? filter.value.toString() : undefined}
|
||||||
key={`nd-filter-${filter.label}`}
|
key={`nd-filter-${filter.label}`}
|
||||||
label={filter.label}
|
label={filter.label}
|
||||||
onChange={filter.onChange}
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
value={filter.value ?? undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch checked={filter?.value ?? false} onChange={filter.onChange} />
|
<Switch defaultChecked={filter?.value ?? false} onChange={filter.onChange} />
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="md" />
|
||||||
<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}
|
||||||
onBlur={(e) => handleYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
|
{!isGenrePage && (
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
defaultValue={query.genreIds}
|
defaultValue={query.genreIds || []}
|
||||||
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
|
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<SelectWithInvalidData
|
<SelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={selectableAlbumArtists}
|
data={selectableAlbumArtists}
|
||||||
defaultValue={query.artistIds ? query.artistIds[0] : undefined}
|
defaultValue={query.artistIds?.[0] || undefined}
|
||||||
disabled={disableArtistFilter}
|
disabled={disableArtistFilter}
|
||||||
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
|
label={t('entity.artist', { count: 1, postProcess: 'titleCase' })}
|
||||||
limit={300}
|
limit={300}
|
||||||
@@ -186,6 +197,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
<Divider my="md" />
|
||||||
<TagFilters />
|
<TagFilters />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -194,17 +206,25 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
|
|||||||
interface TagFilterItemProps {
|
interface TagFilterItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
onChange: (value: null | string) => void;
|
onChange: (value: null | string) => void;
|
||||||
options: string[];
|
options: Array<{ id: string; name: string }>;
|
||||||
tagValue: string;
|
tagValue: string;
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagFilterItem = memo(
|
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
|
||||||
({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
|
const selectData = useMemo(
|
||||||
|
() =>
|
||||||
|
options.map((option) => ({
|
||||||
|
label: option.name,
|
||||||
|
value: option.id,
|
||||||
|
})),
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectWithInvalidData
|
<SelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={options}
|
data={selectData}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
key={tagValue}
|
key={tagValue}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -213,19 +233,7 @@ const TagFilterItem = memo(
|
|||||||
searchable
|
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';
|
TagFilterItem.displayName = 'TagFilterItem';
|
||||||
|
|
||||||
@@ -234,7 +242,7 @@ const TagFilters = () => {
|
|||||||
|
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const tagsQuery = useQuery(
|
const tagsQuery = useSuspenseQuery(
|
||||||
sharedQueries.tags({
|
sharedQueries.tags({
|
||||||
options: {
|
options: {
|
||||||
gcTime: 1000 * 60 * 60,
|
gcTime: 1000 * 60 * 60,
|
||||||
@@ -249,50 +257,27 @@ const TagFilters = () => {
|
|||||||
|
|
||||||
const handleTagFilter = useMemo(
|
const handleTagFilter = useMemo(
|
||||||
() => (tag: string, e: null | string) => {
|
() => (tag: string, e: null | string) => {
|
||||||
setCustom((prev) => {
|
setCustom({ [tag]: e });
|
||||||
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],
|
[setCustom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
return (
|
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
|
||||||
tagsQuery.data?.enumTags?.map((tag) => ({
|
[];
|
||||||
|
|
||||||
|
for (const tag of tagsQuery.data?.enumTags || []) {
|
||||||
|
if (!tagsQuery.data?.excluded.album.includes(tag.name)) {
|
||||||
|
results.push({
|
||||||
label: titleCase(tag.name),
|
label: titleCase(tag.name),
|
||||||
options: tag.options,
|
options: tag.options,
|
||||||
value: tag.name,
|
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 results;
|
||||||
|
}, [tagsQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -300,7 +285,7 @@ const TagFilters = () => {
|
|||||||
<TagFilterItem
|
<TagFilterItem
|
||||||
key={tag.value}
|
key={tag.value}
|
||||||
label={tag.label}
|
label={tag.label}
|
||||||
onChange={tagHandlers.get(tag.value)!}
|
onChange={(e) => handleTagFilter(tag.value, e)}
|
||||||
options={tag.options}
|
options={tag.options}
|
||||||
tagValue={tag.value}
|
tagValue={tag.value}
|
||||||
value={query._custom?.[tag.value] as string | undefined}
|
value={query._custom?.[tag.value] as string | undefined}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { ChangeEvent, useMemo, useState } from 'react';
|
import { ChangeEvent, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
||||||
|
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 { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
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';
|
||||||
@@ -15,7 +16,8 @@ import { 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 { AlbumArtistListSort, GenreListSort, SortOrder } from '/@/shared/types/domain-types';
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
|
import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
interface SubsonicAlbumFiltersProps {
|
interface SubsonicAlbumFiltersProps {
|
||||||
disableArtistFilter?: boolean;
|
disableArtistFilter?: boolean;
|
||||||
@@ -26,12 +28,16 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
|
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =
|
const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =
|
||||||
useAlbumListFilters();
|
useAlbumListFilters();
|
||||||
|
|
||||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||||
|
|
||||||
const albumArtistListQuery = useQuery(
|
const albumArtistListQuery = useSuspenseQuery(
|
||||||
artistsQueries.albumArtistList({
|
artistsQueries.albumArtistList({
|
||||||
options: {
|
options: {
|
||||||
gcTime: 1000 * 60 * 2,
|
gcTime: 1000 * 60 * 2,
|
||||||
@@ -64,20 +70,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
[setAlbumArtist],
|
[setAlbumArtist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const genreListQuery = useQuery(
|
const genreListQuery = useGenreList();
|
||||||
genresQueries.list({
|
|
||||||
options: {
|
|
||||||
gcTime: 1000 * 60 * 2,
|
|
||||||
staleTime: 1000 * 60 * 1,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
sortBy: GenreListSort.NAME,
|
|
||||||
sortOrder: SortOrder.ASC,
|
|
||||||
startIndex: 0,
|
|
||||||
},
|
|
||||||
serverId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
if (!genreListQuery?.data) return [];
|
if (!genreListQuery?.data) return [];
|
||||||
@@ -146,15 +139,18 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
[setMaxYear],
|
[setMaxYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
|
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch checked={filter?.value || false} onChange={filter.onChange} />
|
<Switch defaultChecked={filter.value ?? false} onChange={filter.onChange} />
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
@@ -163,7 +159,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||||
max={5000}
|
max={5000}
|
||||||
min={0}
|
min={0}
|
||||||
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMinYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.maxYear ?? undefined}
|
defaultValue={query.maxYear ?? undefined}
|
||||||
@@ -172,9 +168,10 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||||
max={5000}
|
max={5000}
|
||||||
min={0}
|
min={0}
|
||||||
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMaxYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isGenrePage && (
|
||||||
<Select
|
<Select
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
@@ -184,10 +181,11 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte
|
|||||||
onChange={(e) => handleGenresFilter(e)}
|
onChange={(e) => handleGenresFilter(e)}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={selectableAlbumArtists}
|
data={selectableAlbumArtists}
|
||||||
defaultValue={query.artistIds ?? undefined}
|
defaultValue={query.artistIds ?? []}
|
||||||
disabled={disableArtistFilter}
|
disabled={disableArtistFilter}
|
||||||
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
|
||||||
limit={300}
|
limit={300}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import {
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
parseAsArrayOf,
|
import { useSearchParams } from 'react-router';
|
||||||
parseAsBoolean,
|
|
||||||
parseAsInteger,
|
|
||||||
parseAsJson,
|
|
||||||
parseAsString,
|
|
||||||
useQueryState,
|
|
||||||
} from 'nuqs';
|
|
||||||
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';
|
||||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-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,
|
||||||
|
setSearchParam,
|
||||||
|
} from '/@/renderer/utils/query-params';
|
||||||
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -25,37 +25,155 @@ export const useAlbumListFilters = () => {
|
|||||||
|
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||||
|
|
||||||
const [genreId, setGenreId] = useQueryState(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
FILTER_KEYS.ALBUM.GENRE_ID,
|
|
||||||
parseAsArrayOf(parseAsString),
|
const genreId = useMemo(
|
||||||
|
() => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.GENRE_ID),
|
||||||
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [albumArtist, setAlbumArtist] = useQueryState(
|
const albumArtist = useMemo(
|
||||||
FILTER_KEYS.ALBUM.ARTIST_IDS,
|
() => parseArrayParam(searchParams, FILTER_KEYS.ALBUM.ARTIST_IDS),
|
||||||
parseAsArrayOf(parseAsString),
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [minYear, setMinYear] = useQueryState(FILTER_KEYS.ALBUM.MIN_YEAR, parseAsInteger);
|
const minYear = useMemo(
|
||||||
|
() => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MIN_YEAR),
|
||||||
const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.ALBUM.MAX_YEAR, parseAsInteger);
|
[searchParams],
|
||||||
|
|
||||||
const [favorite, setFavorite] = useQueryState(FILTER_KEYS.ALBUM.FAVORITE, parseAsBoolean);
|
|
||||||
|
|
||||||
const [compilation, setCompilation] = useQueryState(
|
|
||||||
FILTER_KEYS.ALBUM.COMPILATION,
|
|
||||||
parseAsBoolean,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [hasRating, setHasRating] = useQueryState(FILTER_KEYS.ALBUM.HAS_RATING, parseAsBoolean);
|
const maxYear = useMemo(
|
||||||
|
() => parseIntParam(searchParams, FILTER_KEYS.ALBUM.MAX_YEAR),
|
||||||
const [recentlyPlayed, setRecentlyPlayed] = useQueryState(
|
[searchParams],
|
||||||
FILTER_KEYS.ALBUM.RECENTLY_PLAYED,
|
|
||||||
parseAsBoolean,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [custom, setCustom] = useQueryState(
|
const favorite = useMemo(
|
||||||
FILTER_KEYS.ALBUM._CUSTOM,
|
() => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.FAVORITE),
|
||||||
parseAsJson(customFiltersSchema),
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const compilation = useMemo(
|
||||||
|
() => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.COMPILATION),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRating = useMemo(
|
||||||
|
() => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.HAS_RATING),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentlyPlayed = useMemo(
|
||||||
|
() => parseBooleanParam(searchParams, FILTER_KEYS.ALBUM.RECENTLY_PLAYED),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const custom = useMemo(
|
||||||
|
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.ALBUM._CUSTOM),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use a ref to track the latest custom filters to avoid stale state during batched updates
|
||||||
|
const customRef = useRef<null | Record<string, any> | undefined>(custom);
|
||||||
|
useEffect(() => {
|
||||||
|
customRef.current = custom;
|
||||||
|
}, [custom]);
|
||||||
|
|
||||||
|
const setGenreId = useCallback(
|
||||||
|
(value: null | string[]) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAlbumArtist = useCallback(
|
||||||
|
(value: null | string[]) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.ARTIST_IDS, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMinYear = useCallback(
|
||||||
|
(value: null | number) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MIN_YEAR, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMaxYear = useCallback(
|
||||||
|
(value: null | number) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.MAX_YEAR, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFavorite = useCallback(
|
||||||
|
(value: boolean | null) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.FAVORITE, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCompilation = useCallback(
|
||||||
|
(value: boolean | null) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.COMPILATION, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setHasRating = useCallback(
|
||||||
|
(value: boolean | null) => {
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.HAS_RATING, value), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setRecentlyPlayed = useCallback(
|
||||||
|
(value: boolean | null) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.RECENTLY_PLAYED, value),
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCustom = useCallback(
|
||||||
|
(value: null | Record<string, any>) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const previousValue = prev.get(FILTER_KEYS.ALBUM._CUSTOM);
|
||||||
|
|
||||||
|
const newCustom = {
|
||||||
|
...(previousValue ? JSON.parse(previousValue) : {}),
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredNewCustom = Object.fromEntries(
|
||||||
|
Object.entries(newCustom).filter(
|
||||||
|
([, value]) => value !== null && value !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
prev.set(FILTER_KEYS.ALBUM._CUSTOM, JSON.stringify(filteredNewCustom));
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { parseAsJson, 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 { 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';
|
||||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-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 { parseCustomFiltersParam, setJsonSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { PlaylistListSort } from '/@/shared/types/domain-types';
|
import { PlaylistListSort } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -13,14 +15,53 @@ export const usePlaylistListFilters = () => {
|
|||||||
|
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||||
|
|
||||||
const [custom, setCustom] = useQueryState('playlistCustom', parseAsJson(customFiltersSchema));
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const query = {
|
const custom = useMemo(
|
||||||
|
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.PLAYLIST.CUSTOM),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
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.PLAYLIST.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.PLAYLIST.CUSTOM, newValue);
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useMemo(
|
||||||
|
() => ({
|
||||||
_custom: custom ?? undefined,
|
_custom: custom ?? undefined,
|
||||||
searchTerm: searchTerm ?? undefined,
|
searchTerm: searchTerm ?? undefined,
|
||||||
sortBy: sortByFilter[FILTER_KEYS.SHARED.SORT_BY] ?? undefined,
|
sortBy: sortByFilter.sortBy ?? undefined,
|
||||||
sortOrder: sortOrderFilter[FILTER_KEYS.SHARED.SORT_ORDER] ?? undefined,
|
sortOrder: sortOrderFilter.sortOrder ?? undefined,
|
||||||
};
|
}),
|
||||||
|
[custom, searchTerm, sortByFilter.sortBy, sortOrderFilter.sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import {
|
import { useCallback, useMemo } from 'react';
|
||||||
parseAsArrayOf,
|
import { useSearchParams } from 'react-router';
|
||||||
parseAsBoolean,
|
|
||||||
parseAsInteger,
|
|
||||||
parseAsJson,
|
|
||||||
parseAsString,
|
|
||||||
useQueryState,
|
|
||||||
} from 'nuqs';
|
|
||||||
|
|
||||||
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';
|
||||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-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 { SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -21,30 +23,123 @@ export const usePlaylistSongListFilters = () => {
|
|||||||
|
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||||
|
|
||||||
const [albumIds, setAlbumIds] = useQueryState(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
FILTER_KEYS.SONG.ALBUM_IDS,
|
|
||||||
parseAsArrayOf(parseAsString),
|
const albumIds = useMemo(
|
||||||
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
|
||||||
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [genreId, setGenreId] = useQueryState(
|
const genreId = useMemo(
|
||||||
FILTER_KEYS.SONG.GENRE_ID,
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
|
||||||
parseAsArrayOf(parseAsString),
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [artistIds, setArtistIds] = useQueryState(
|
const artistIds = useMemo(
|
||||||
FILTER_KEYS.SONG.ARTIST_IDS,
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS),
|
||||||
parseAsArrayOf(parseAsString),
|
[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(
|
const custom = useMemo(
|
||||||
FILTER_KEYS.SONG._CUSTOM,
|
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
|
||||||
parseAsJson(customFiltersSchema),
|
[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 query = {
|
const query = {
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
import { parseAsString, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
||||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const useMusicFolderIdFilter = (defaultValue: null | string, listKey: ItemListKey) => {
|
export const useMusicFolderIdFilter = (defaultValue: null | string, listKey: ItemListKey) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const persisted = getFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID);
|
const persisted = getFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID);
|
||||||
|
|
||||||
const [musicFolderId, setMusicFolderId] = useQueryState(
|
const musicFolderId = useMemo(() => {
|
||||||
FILTER_KEYS.SHARED.MUSIC_FOLDER_ID,
|
const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.MUSIC_FOLDER_ID);
|
||||||
getDefaultMusicFolderId(defaultValue, persisted),
|
return value ?? persisted ?? defaultValue ?? undefined;
|
||||||
);
|
}, [searchParams, persisted, defaultValue]);
|
||||||
|
|
||||||
const handleSetMusicFolderId = (musicFolderId: string) => {
|
const handleSetMusicFolderId = (musicFolderId: string) => {
|
||||||
setMusicFolderId(musicFolderId);
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const newParams = setSearchParam(
|
||||||
|
prev,
|
||||||
|
FILTER_KEYS.SHARED.MUSIC_FOLDER_ID,
|
||||||
|
musicFolderId,
|
||||||
|
);
|
||||||
|
return newParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
setFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, musicFolderId);
|
setFilter(FILTER_KEYS.SHARED.MUSIC_FOLDER_ID, musicFolderId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined,
|
musicFolderId,
|
||||||
setMusicFolderId: handleSetMusicFolderId,
|
setMusicFolderId: handleSetMusicFolderId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultMusicFolderId = (defaultValue: null | string, persisted: string | undefined) => {
|
|
||||||
if (persisted) {
|
|
||||||
return parseAsString.withDefault(persisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultValue) {
|
|
||||||
return parseAsString.withDefault(defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseAsString;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
import { parseAsString, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
|
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||||
|
|
||||||
export const useSearchTermFilter = (defaultValue?: string) => {
|
export const useSearchTermFilter = (defaultValue?: string) => {
|
||||||
const [searchTerm, setSearchTerm] = useQueryState(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
FILTER_KEYS.SHARED.SEARCH_TERM,
|
|
||||||
defaultValue ? parseAsString.withDefault(defaultValue) : parseAsString,
|
const searchTerm = useMemo(() => {
|
||||||
);
|
const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SEARCH_TERM);
|
||||||
|
return value ?? defaultValue ?? undefined;
|
||||||
|
}, [searchParams, defaultValue]);
|
||||||
|
|
||||||
const handleSetSearchTerm = (value: null | string) => {
|
const handleSetSearchTerm = (value: null | string) => {
|
||||||
setSearchTerm(value === '' ? null : value);
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const newParams = setSearchParam(
|
||||||
|
prev,
|
||||||
|
FILTER_KEYS.SHARED.SEARCH_TERM,
|
||||||
|
value === '' ? null : value,
|
||||||
|
);
|
||||||
|
return newParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300);
|
const debouncedSetSearchTerm = useDebouncedCallback(handleSetSearchTerm, 300);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm || undefined,
|
searchTerm: searchTerm || undefined,
|
||||||
setSearchTerm: debouncedSetSearchTerm,
|
setSearchTerm: debouncedSetSearchTerm,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { parseAsString, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const useSelectFilter = (
|
export const useSelectFilter = (
|
||||||
@@ -11,31 +13,29 @@ export const useSelectFilter = (
|
|||||||
) => {
|
) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const persisted = getFilter(filterKey);
|
const persisted = getFilter(filterKey);
|
||||||
|
|
||||||
const [value, setValue] = useQueryState(filterKey, getDefaultValue(defaultValue, persisted));
|
const value = useMemo(() => {
|
||||||
|
const paramValue = parseStringParam(searchParams, filterKey);
|
||||||
|
return paramValue ?? persisted ?? defaultValue ?? undefined;
|
||||||
|
}, [searchParams, filterKey, persisted, defaultValue]);
|
||||||
|
|
||||||
const handleSetValue = (newValue: string) => {
|
const handleSetValue = (newValue: string) => {
|
||||||
setValue(newValue);
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const newParams = setSearchParam(prev, filterKey, newValue);
|
||||||
|
return newParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
setFilter(filterKey, newValue);
|
setFilter(filterKey, newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[filterKey]: value ?? undefined,
|
[filterKey]: value,
|
||||||
setValue: handleSetValue,
|
setValue: handleSetValue,
|
||||||
value: value ?? undefined,
|
value,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultValue = (defaultValue: null | string, persisted: string | undefined) => {
|
|
||||||
if (persisted) {
|
|
||||||
return parseAsString.withDefault(persisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultValue) {
|
|
||||||
return parseAsString.withDefault(defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseAsString;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,40 +1,37 @@
|
|||||||
import { parseAsString, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
||||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {
|
export const useSortByFilter = <TSortBy>(defaultValue: null | string, listKey: ItemListKey) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const persisted = getFilter(FILTER_KEYS.SHARED.SORT_BY);
|
const persisted = getFilter(FILTER_KEYS.SHARED.SORT_BY);
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useQueryState(
|
const sortBy = useMemo(() => {
|
||||||
FILTER_KEYS.SHARED.SORT_BY,
|
const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_BY);
|
||||||
getDefaultSortBy(defaultValue, persisted),
|
return (value ?? persisted ?? defaultValue ?? undefined) as TSortBy;
|
||||||
);
|
}, [searchParams, persisted, defaultValue]);
|
||||||
|
|
||||||
const handleSetSortBy = (sortBy: string) => {
|
const handleSetSortBy = (sortBy: string) => {
|
||||||
setSortBy(sortBy);
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_BY, sortBy);
|
||||||
|
return newParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy);
|
setFilter(FILTER_KEYS.SHARED.SORT_BY, sortBy);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[FILTER_KEYS.SHARED.SORT_BY]: sortBy as TSortBy,
|
|
||||||
setSortBy: handleSetSortBy,
|
setSortBy: handleSetSortBy,
|
||||||
|
sortBy,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultSortBy = (defaultValue: null | string, persisted: string | undefined) => {
|
|
||||||
if (persisted) {
|
|
||||||
return parseAsString.withDefault(persisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultValue) {
|
|
||||||
return parseAsString.withDefault(defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseAsString;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,41 +1,38 @@
|
|||||||
import { parseAsString, useQueryState } from 'nuqs';
|
import { useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
import { useListFilterPersistence } from '/@/renderer/features/shared/hooks/use-list-filter-persistence';
|
||||||
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
import { useCurrentServer } from '/@/renderer/store';
|
||||||
|
import { parseStringParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
import { SortOrder } from '/@/shared/types/domain-types';
|
import { SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const useSortOrderFilter = (defaultValue: null | string, listKey: ItemListKey) => {
|
export const useSortOrderFilter = (defaultValue: null | string, listKey: ItemListKey) => {
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
const { getFilter, setFilter } = useListFilterPersistence(server.id, listKey);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const persisted = getFilter(FILTER_KEYS.SHARED.SORT_ORDER);
|
const persisted = getFilter(FILTER_KEYS.SHARED.SORT_ORDER);
|
||||||
|
|
||||||
const [sortOrder, setSortOrder] = useQueryState(
|
const sortOrder = useMemo(() => {
|
||||||
FILTER_KEYS.SHARED.SORT_ORDER,
|
const value = parseStringParam(searchParams, FILTER_KEYS.SHARED.SORT_ORDER);
|
||||||
getDefaultSortOrder(defaultValue, persisted),
|
return (value ?? persisted ?? defaultValue ?? undefined) as SortOrder;
|
||||||
);
|
}, [searchParams, persisted, defaultValue]);
|
||||||
|
|
||||||
const handleSetSortOrder = (sortOrder: SortOrder) => {
|
const handleSetSortOrder = (sortOrder: SortOrder) => {
|
||||||
setSortOrder(sortOrder);
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const newParams = setSearchParam(prev, FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
|
||||||
|
return newParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
|
setFilter(FILTER_KEYS.SHARED.SORT_ORDER, sortOrder);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder as SortOrder,
|
|
||||||
setSortOrder: handleSetSortOrder,
|
setSortOrder: handleSetSortOrder,
|
||||||
|
sortOrder,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultSortOrder = (defaultValue: null | string, persisted: string | undefined) => {
|
|
||||||
if (persisted) {
|
|
||||||
return parseAsString.withDefault(persisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defaultValue) {
|
|
||||||
return parseAsString.withDefault(defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseAsString;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ enum AlbumFilterKeys {
|
|||||||
HAS_RATING = 'hasRating',
|
HAS_RATING = 'hasRating',
|
||||||
MAX_YEAR = 'maxYear',
|
MAX_YEAR = 'maxYear',
|
||||||
MIN_YEAR = 'minYear',
|
MIN_YEAR = 'minYear',
|
||||||
RECENTLY_PLAYED = 'recentlyPlayed',
|
RECENTLY_PLAYED = 'isRecentlyPlayed',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ArtistFilterKeys {
|
enum ArtistFilterKeys {
|
||||||
@@ -63,7 +63,7 @@ enum SongFilterKeys {
|
|||||||
ALBUM_IDS = 'albumIds',
|
ALBUM_IDS = 'albumIds',
|
||||||
ARTIST_IDS = 'artistIds',
|
ARTIST_IDS = 'artistIds',
|
||||||
FAVORITE = 'favorite',
|
FAVORITE = 'favorite',
|
||||||
GENRE_ID = 'genreId',
|
GENRE_ID = 'genreIds',
|
||||||
MAX_YEAR = 'maxYear',
|
MAX_YEAR = 'maxYear',
|
||||||
MIN_YEAR = 'minYear',
|
MIN_YEAR = 'minYear',
|
||||||
}
|
}
|
||||||
@@ -73,10 +73,15 @@ const PaginationFilterKeys = {
|
|||||||
SCROLL_OFFSET: 'scrollOffset',
|
SCROLL_OFFSET: 'scrollOffset',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum PlaylistFilterKeys {
|
||||||
|
CUSTOM = '_custom',
|
||||||
|
}
|
||||||
|
|
||||||
export const FILTER_KEYS = {
|
export const FILTER_KEYS = {
|
||||||
ALBUM: AlbumFilterKeys,
|
ALBUM: AlbumFilterKeys,
|
||||||
ARTIST: ArtistFilterKeys,
|
ARTIST: ArtistFilterKeys,
|
||||||
PAGINATION: PaginationFilterKeys,
|
PAGINATION: PaginationFilterKeys,
|
||||||
|
PLAYLIST: PlaylistFilterKeys,
|
||||||
SHARED: SharedFilterKeys,
|
SHARED: SharedFilterKeys,
|
||||||
SONG: SongFilterKeys,
|
SONG: SongFilterKeys,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,27 +3,26 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
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 { 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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
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 { 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 { Stack } from '/@/shared/components/stack/stack';
|
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 { 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';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
interface JellyfinSongFiltersProps {
|
export const JellyfinSongFilters = () => {
|
||||||
customFilters?: Partial<SongListFilter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) => {
|
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
|
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
// Despite the fact that getTags returns genres, it only returns genre names.
|
// Despite the fact that getTags returns genres, it only returns genre names.
|
||||||
@@ -103,23 +102,27 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
[setMaxYear],
|
[setMaxYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
|
||||||
|
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
|
||||||
|
|
||||||
const handleGenresFilter = useMemo(
|
const handleGenresFilter = useMemo(
|
||||||
() => (e: string[] | undefined) => {
|
() => (e: string[] | undefined) => {
|
||||||
setCustom((prev) => {
|
setCustom((prev) => {
|
||||||
|
const current = prev ?? {};
|
||||||
|
|
||||||
if (!e || e.length === 0) {
|
if (!e || e.length === 0) {
|
||||||
// Remove GenreIds and IncludeItemTypes if genres are cleared
|
// Remove GenreIds and IncludeItemTypes if genres are cleared
|
||||||
const rest = { ...prev };
|
const rest = { ...current };
|
||||||
delete rest.GenreIds;
|
delete rest.GenreIds;
|
||||||
delete rest.IncludeItemTypes;
|
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 Object.keys(rest).length === 0 ? null : rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...current,
|
||||||
GenreIds: e.join(','),
|
GenreIds: e.join(','),
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
...prev?.jellyfin,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -128,38 +131,22 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
|
|
||||||
const handleTagFilter = useMemo(
|
const handleTagFilter = useMemo(
|
||||||
() => (e: string[] | undefined) => {
|
() => (e: string[] | undefined) => {
|
||||||
setCustom((prev) => {
|
setCustom({ Tags: e?.join('|') ?? null });
|
||||||
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],
|
[setCustom],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{yesNoFilters.map((filter) => (
|
{yesNoFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`nd-filter-${filter.label}`}>
|
<YesNoSelect
|
||||||
<Text>{filter.label}</Text>
|
defaultValue={filter.value ? filter.value.toString() : undefined}
|
||||||
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
|
key={`jf-filter-${filter.label}`}
|
||||||
</Group>
|
label={filter.label}
|
||||||
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="md" />
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
defaultValue={query.minYear ?? undefined}
|
defaultValue={query.minYear ?? undefined}
|
||||||
@@ -167,7 +154,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
|
||||||
max={2300}
|
max={2300}
|
||||||
min={1700}
|
min={1700}
|
||||||
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMinYearFilter(e)}
|
||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -176,12 +163,11 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
|
||||||
max={2300}
|
max={2300}
|
||||||
min={1700}
|
min={1700}
|
||||||
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleMaxYearFilter(e)}
|
||||||
required={!!query.minYear}
|
required={!!query.minYear}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{!isGenrePage && (
|
{!isGenrePage && (
|
||||||
<Group grow>
|
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
@@ -189,12 +175,9 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
|
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => handleGenresFilter(e)}
|
onChange={(e) => handleGenresFilter(e)}
|
||||||
searchable
|
searchable
|
||||||
width={250}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
|
||||||
<Group grow>
|
|
||||||
<MultiSelectWithInvalidData
|
<MultiSelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={tagsQuery.data.boolTags}
|
data={tagsQuery.data.boolTags}
|
||||||
@@ -202,9 +185,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
|
|||||||
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
label={t('common.tags', { postProcess: 'sentenceCase' })}
|
||||||
onChange={(e) => handleTagFilter(e)}
|
onChange={(e) => handleTagFilter(e)}
|
||||||
searchable
|
searchable
|
||||||
width={250}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { memo, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MultiSelectWithInvalidData,
|
MultiSelectWithInvalidData,
|
||||||
SelectWithInvalidData,
|
SelectWithInvalidData,
|
||||||
} from '/@/renderer/components/select-with-invalid-data';
|
} from '/@/renderer/components/select-with-invalid-data';
|
||||||
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
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 { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
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 { titleCase } from '/@/renderer/utils';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
|
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';
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const NavidromeSongFilters = () => {
|
export const NavidromeSongFilters = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters();
|
const { query, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters();
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
const genreListQuery = useGenreList();
|
const genreListQuery = useGenreList();
|
||||||
|
|
||||||
const genreList = useMemo(() => {
|
const genreList = useMemo(() => {
|
||||||
@@ -69,33 +73,39 @@ export const NavidromeSongFilters = () => {
|
|||||||
[setMinYear, setMaxYear],
|
[setMinYear, setMaxYear],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const debouncedHandleYearFilter = useDebouncedCallback(handleYearFilter, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{yesNoUndefinedFilters.map((filter) => (
|
{yesNoUndefinedFilters.map((filter) => (
|
||||||
<YesNoSelect
|
<YesNoSelect
|
||||||
|
clearable
|
||||||
|
defaultValue={filter.value ? filter.value.toString() : undefined}
|
||||||
key={`nd-filter-${filter.label}`}
|
key={`nd-filter-${filter.label}`}
|
||||||
label={filter.label}
|
label={filter.label}
|
||||||
onChange={filter.onChange}
|
onChange={(e) => filter.onChange(e ? e === 'true' : undefined)}
|
||||||
value={filter.value ?? undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
<Divider my="md" />
|
||||||
<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}
|
||||||
onBlur={(e) => handleYearFilter(e.currentTarget.value)}
|
onChange={(e) => debouncedHandleYearFilter(e)}
|
||||||
/>
|
/>
|
||||||
|
{!isGenrePage && (
|
||||||
<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 && e.length > 0 ? setGenreId(e) : setGenreId(null))}
|
onChange={(e) => (e && e.length > 0 ? setGenreId(e) : setGenreId(null))}
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<Divider my="md" />
|
||||||
<TagFilters />
|
<TagFilters />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -104,17 +114,25 @@ export const NavidromeSongFilters = () => {
|
|||||||
interface TagFilterItemProps {
|
interface TagFilterItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
onChange: (value: null | string) => void;
|
onChange: (value: null | string) => void;
|
||||||
options: string[];
|
options: Array<{ id: string; name: string }>;
|
||||||
tagValue: string;
|
tagValue: string;
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagFilterItem = memo(
|
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
|
||||||
({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
|
const selectData = useMemo(
|
||||||
|
() =>
|
||||||
|
options.map((option) => ({
|
||||||
|
label: option.name,
|
||||||
|
value: option.id,
|
||||||
|
})),
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectWithInvalidData
|
<SelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={options}
|
data={selectData}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
key={tagValue}
|
key={tagValue}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -123,19 +141,7 @@ const TagFilterItem = memo(
|
|||||||
searchable
|
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';
|
TagFilterItem.displayName = 'TagFilterItem';
|
||||||
|
|
||||||
@@ -144,65 +150,36 @@ const TagFilters = () => {
|
|||||||
|
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const tagsQuery = useQuery(
|
const tagsQuery = useSuspenseQuery(
|
||||||
sharedQueries.tags({
|
sharedQueries.tags({
|
||||||
options: {
|
query: { type: LibraryItem.SONG },
|
||||||
gcTime: 1000 * 60 * 60,
|
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: LibraryItem.SONG,
|
|
||||||
},
|
|
||||||
serverId,
|
serverId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagFilter = useMemo(
|
const handleTagFilter = useMemo(
|
||||||
() => (tag: string, e: null | string) => {
|
() => (tag: string, e: null | string) => {
|
||||||
setCustom((prev) => {
|
setCustom({ [tag]: e });
|
||||||
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],
|
[setCustom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
return (
|
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
|
||||||
tagsQuery.data?.enumTags?.map((tag) => ({
|
[];
|
||||||
|
|
||||||
|
for (const tag of tagsQuery.data?.enumTags || []) {
|
||||||
|
if (!tagsQuery.data?.excluded.song.includes(tag.name)) {
|
||||||
|
results.push({
|
||||||
label: titleCase(tag.name),
|
label: titleCase(tag.name),
|
||||||
options: tag.options,
|
options: tag.options,
|
||||||
value: tag.name,
|
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 results;
|
||||||
|
}, [tagsQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +187,7 @@ const TagFilters = () => {
|
|||||||
<TagFilterItem
|
<TagFilterItem
|
||||||
key={tag.value}
|
key={tag.value}
|
||||||
label={tag.label}
|
label={tag.label}
|
||||||
onChange={tagHandlers.get(tag.value)!}
|
onChange={(e) => handleTagFilter(tag.value, e)}
|
||||||
options={tag.options}
|
options={tag.options}
|
||||||
tagValue={tag.value}
|
tagValue={tag.value}
|
||||||
value={query._custom?.[tag.value] as string | undefined}
|
value={query._custom?.[tag.value] as string | undefined}
|
||||||
|
|||||||
@@ -2,23 +2,21 @@ import { ChangeEvent, useMemo } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
|
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 { useGenreList } from '/@/renderer/features/genres/api/genres-api';
|
||||||
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
|
||||||
import { SongListFilter } from '/@/renderer/store';
|
|
||||||
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 { 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';
|
||||||
|
|
||||||
interface SubsonicSongFiltersProps {
|
export const SubsonicSongFilters = () => {
|
||||||
customFilters?: Partial<SongListFilter>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { query, setFavorite, setGenreId } = useSongListFilters();
|
const { query, setFavorite, setGenreId } = useSongListFilters();
|
||||||
|
|
||||||
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
const isGenrePage = customFilters?.genreIds !== undefined;
|
const isGenrePage = customFilters?.genreIds !== undefined;
|
||||||
|
|
||||||
const genreListQuery = useGenreList();
|
const genreListQuery = useGenreList();
|
||||||
@@ -53,27 +51,26 @@ export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="0.8rem">
|
<Stack p="md">
|
||||||
{toggleFilters.map((filter) => (
|
{toggleFilters.map((filter) => (
|
||||||
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
|
||||||
<Text>{filter.label}</Text>
|
<Text>{filter.label}</Text>
|
||||||
<Switch checked={filter.value ?? false} onChange={filter.onChange} />
|
<Switch defaultChecked={filter.value ?? false} onChange={filter.onChange} />
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
<Divider my="0.5rem" />
|
|
||||||
<Group grow>
|
|
||||||
{!isGenrePage && (
|
{!isGenrePage && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
<SelectWithInvalidData
|
<SelectWithInvalidData
|
||||||
clearable
|
clearable
|
||||||
data={genreList}
|
data={genreList}
|
||||||
defaultValue={query.genreId ? query.genreId[0] : undefined}
|
defaultValue={query.genreIds ? query.genreIds[0] : undefined}
|
||||||
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
|
||||||
onChange={handleGenresFilter}
|
onChange={handleGenresFilter}
|
||||||
searchable
|
searchable
|
||||||
width={150}
|
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import {
|
|
||||||
parseAsArrayOf,
|
|
||||||
parseAsBoolean,
|
|
||||||
parseAsInteger,
|
|
||||||
parseAsJson,
|
|
||||||
parseAsString,
|
|
||||||
useQueryState,
|
|
||||||
} from 'nuqs';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
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';
|
||||||
import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-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 { SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -25,30 +26,123 @@ export const useSongListFilters = () => {
|
|||||||
|
|
||||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||||
|
|
||||||
const [albumIds, setAlbumIds] = useQueryState(
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
FILTER_KEYS.SONG.ALBUM_IDS,
|
|
||||||
parseAsArrayOf(parseAsString),
|
const albumIds = useMemo(
|
||||||
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ALBUM_IDS),
|
||||||
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [genreId, setGenreId] = useQueryState(
|
const genreId = useMemo(
|
||||||
FILTER_KEYS.SONG.GENRE_ID,
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.GENRE_ID),
|
||||||
parseAsArrayOf(parseAsString),
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [artistIds, setArtistIds] = useQueryState(
|
const artistIds = useMemo(
|
||||||
FILTER_KEYS.SONG.ARTIST_IDS,
|
() => parseArrayParam(searchParams, FILTER_KEYS.SONG.ARTIST_IDS),
|
||||||
parseAsArrayOf(parseAsString),
|
[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(
|
const custom = useMemo(
|
||||||
FILTER_KEYS.SONG._CUSTOM,
|
() => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM),
|
||||||
parseAsJson(customFiltersSchema),
|
[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(() => {
|
const clear = useCallback(() => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { NuqsAdapter } from '@offlegacy/nuqs-hash-router';
|
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Navigate, Outlet } from 'react-router';
|
import { Navigate, Outlet } from 'react-router';
|
||||||
@@ -43,9 +42,5 @@ export const AppOutlet = () => {
|
|||||||
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;
|
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Outlet />;
|
||||||
<NuqsAdapter>
|
|
||||||
<Outlet />
|
|
||||||
</NuqsAdapter>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { customFiltersSchema } from '/@/renderer/features/shared/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string array from URLSearchParams
|
||||||
|
* Returns undefined if the key doesn't exist or array is empty
|
||||||
|
*/
|
||||||
|
export const parseArrayParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
): string[] | undefined => {
|
||||||
|
const values = searchParams.getAll(key);
|
||||||
|
return values.length > 0 ? values : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a boolean from URLSearchParams
|
||||||
|
* Returns undefined if the key doesn't exist
|
||||||
|
*/
|
||||||
|
export const parseBooleanParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
): boolean | undefined => {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (value === null) return undefined;
|
||||||
|
return value === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an integer from URLSearchParams
|
||||||
|
* Returns undefined if the key doesn't exist or value is invalid
|
||||||
|
*/
|
||||||
|
export const parseIntParam = (searchParams: URLSearchParams, key: string): number | undefined => {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (value === null) return undefined;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return isNaN(parsed) ? undefined : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string from URLSearchParams
|
||||||
|
* Returns undefined if the key doesn't exist
|
||||||
|
*/
|
||||||
|
export const parseStringParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
): string | undefined => {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
return value === null ? undefined : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON from URLSearchParams
|
||||||
|
* Returns undefined if the key doesn't exist or parsing fails
|
||||||
|
*/
|
||||||
|
export const parseJsonParam = <T = unknown>(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
): T | undefined => {
|
||||||
|
const value = searchParams.get(key);
|
||||||
|
if (value === null) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
// Validate against schema if provided
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or remove a value in URLSearchParams
|
||||||
|
* If value is null or undefined, removes the key
|
||||||
|
*/
|
||||||
|
export const setSearchParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: boolean | null | number | Record<string, any> | string | string[] | undefined,
|
||||||
|
): URLSearchParams => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
newParams.delete(key);
|
||||||
|
return newParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
newParams.delete(key);
|
||||||
|
value.forEach((v) => newParams.append(key, String(v)));
|
||||||
|
return newParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
newParams.set(key, String(value));
|
||||||
|
return newParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
newParams.set(key, String(value));
|
||||||
|
return newParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
newParams.set(key, value as string);
|
||||||
|
return newParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or remove a JSON value in URLSearchParams
|
||||||
|
* If value is null or undefined, removes the key
|
||||||
|
*/
|
||||||
|
export const setJsonSearchParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: null | Record<string, any> | undefined,
|
||||||
|
): URLSearchParams => {
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
newParams.delete(key);
|
||||||
|
return newParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
newParams.set(key, JSON.stringify(value));
|
||||||
|
return newParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse custom filters from URLSearchParams with validation
|
||||||
|
*/
|
||||||
|
export const parseCustomFiltersParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
): Record<string, any> | undefined => {
|
||||||
|
const value = parseJsonParam(searchParams, key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return customFiltersSchema.parse(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,12 +2,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Select, SelectProps } from '/@/shared/components/select/select';
|
import { Select, SelectProps } from '/@/shared/components/select/select';
|
||||||
|
|
||||||
export interface YesNoSelectProps extends Omit<SelectProps, 'data' | 'onChange' | 'value'> {
|
export interface YesNoSelectProps extends SelectProps {}
|
||||||
onChange: (e?: boolean) => void;
|
|
||||||
value?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) => {
|
export const YesNoSelect = ({ ...props }: YesNoSelectProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,10 +20,6 @@ export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) =>
|
|||||||
value: 'true',
|
value: 'true',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(e) => {
|
|
||||||
onChange(e ? e === 'true' : undefined);
|
|
||||||
}}
|
|
||||||
value={value !== undefined ? value.toString() : null}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1224,7 +1224,7 @@ export type ControllerEndpoint = {
|
|||||||
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
getSongListCount: (args: SongListCountArgs) => Promise<number>;
|
||||||
getStreamUrl: (args: StreamArgs) => string;
|
getStreamUrl: (args: StreamArgs) => string;
|
||||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
getTags?: (args: TagArgs) => Promise<TagResponses>;
|
getTags?: (args: TagArgs) => Promise<TagsResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
@@ -1316,7 +1316,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getStructuredLyrics?: (
|
getStructuredLyrics?: (
|
||||||
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
args: ReplaceApiClientProps<StructuredLyricsArgs>,
|
||||||
) => Promise<StructuredLyric[]>;
|
) => Promise<StructuredLyric[]>;
|
||||||
getTags?: (args: ReplaceApiClientProps<TagArgs>) => Promise<TagResponses>;
|
getTags?: (args: ReplaceApiClientProps<TagArgs>) => Promise<TagsResponse>;
|
||||||
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
||||||
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||||
@@ -1408,8 +1408,9 @@ export type StructuredUnsyncedLyric = Omit<FullLyricsMetadata, 'lyrics'> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Tag = {
|
export type Tag = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
options: string[];
|
options: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TagArgs = BaseEndpointArgs & {
|
export type TagArgs = BaseEndpointArgs & {
|
||||||
@@ -1421,9 +1422,13 @@ export type TagQuery = {
|
|||||||
type: LibraryItem.ALBUM | LibraryItem.SONG;
|
type: LibraryItem.ALBUM | LibraryItem.SONG;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TagResponses = {
|
export type TagsResponse = {
|
||||||
boolTags?: string[];
|
boolTags?: string[];
|
||||||
enumTags?: Tag[];
|
enumTags?: { name: string; options: { id: string; name: string }[] }[];
|
||||||
|
excluded: {
|
||||||
|
album: string[];
|
||||||
|
song: string[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseEndpointArgsWithServer = {
|
type BaseEndpointArgsWithServer = {
|
||||||
|
|||||||
Reference in New Issue
Block a user