diff --git a/src/renderer/components/select-with-invalid-data/index.tsx b/src/renderer/components/select-with-invalid-data/index.tsx index 07abbce1a..9149cf8c4 100644 --- a/src/renderer/components/select-with-invalid-data/index.tsx +++ b/src/renderer/components/select-with-invalid-data/index.tsx @@ -38,10 +38,16 @@ export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectPr ); }; -export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: MultiSelectProps) => { +export const MultiSelectWithInvalidData = ({ + data, + defaultValue, + value, + ...props +}: MultiSelectProps) => { const { t } = useTranslation(); + const currentValue = value ?? defaultValue; const [fullData, missing] = useMemo(() => { - if (defaultValue?.length) { + if (currentValue?.length) { const validValues = new Set(); for (const item of data || []) { if (typeof item === 'string') { @@ -53,9 +59,9 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul const missingFields: string[] = []; - for (const value of defaultValue) { - if (!validValues.has(value)) { - missingFields.push(value); + for (const val of currentValue) { + if (!validValues.has(val)) { + missingFields.push(val); } } @@ -65,7 +71,7 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul } return [data, []]; - }, [data, defaultValue]); + }, [data, currentValue]); const error = useMemo( () => @@ -75,5 +81,13 @@ export const MultiSelectWithInvalidData = ({ data, defaultValue, ...props }: Mul [missing, t], ); - return ; + return ( + + ); }; diff --git a/src/renderer/features/albums/components/jellyfin-album-filters.tsx b/src/renderer/features/albums/components/jellyfin-album-filters.tsx index 89c68df7f..5597d81b4 100644 --- a/src/renderer/features/albums/components/jellyfin-album-filters.tsx +++ b/src/renderer/features/albums/components/jellyfin-album-filters.tsx @@ -15,6 +15,7 @@ import { } from '/@/renderer/features/shared/components/multi-select-rows'; import { useCurrentServerId } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -44,6 +45,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte const isGenrePage = customFilters?.genreIds !== undefined; const { + clear, query, setAlbumArtist, setCompilation, @@ -297,10 +299,10 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte {yesNoFilter.map((filter) => ( filter.onChange(e ? e === 'true' : undefined)} + value={filter.value ? filter.value.toString() : undefined} /> ))} {!disableArtistFilter && ( @@ -338,35 +340,39 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte debouncedHandleMinYearFilter(e)} required={!!query.minYear} + value={query.minYear ?? undefined} /> debouncedHandleMaxYearFilter(e)} required={!!query.minYear} + value={query.maxYear ?? undefined} /> {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( )} + + ); }; diff --git a/src/renderer/features/albums/components/navidrome-album-filters.tsx b/src/renderer/features/albums/components/navidrome-album-filters.tsx index 0461c6501..2b755dfec 100644 --- a/src/renderer/features/albums/components/navidrome-album-filters.tsx +++ b/src/renderer/features/albums/components/navidrome-album-filters.tsx @@ -17,6 +17,7 @@ import { useCurrentServer, useCurrentServerId } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { titleCase } from '/@/renderer/utils'; import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -45,6 +46,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil const isGenrePage = customFilters?.genreIds !== undefined; const { + clear, query, setAlbumArtist, setCompilation, @@ -285,11 +287,11 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil { setFavorite(segmentValueToBoolean(value)); }} size="sm" + value={booleanToSegmentValue(query.favorite)} w="100%" /> @@ -299,18 +301,18 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil { setCompilation(segmentValueToBoolean(value)); }} size="sm" + value={booleanToSegmentValue(query.compilation)} w="100%" /> {toggleFilters.map((filter) => ( {filter.label} - + ))} {!disableArtistFilter && ( @@ -346,15 +348,19 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil )} debouncedHandleYearFilter(e)} + value={query.minYear ?? undefined} /> + + ); }; @@ -377,7 +383,7 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI [options], ); - const defaultValue = useMemo(() => { + const currentValue = useMemo(() => { if (!value) return []; return Array.isArray(value) ? value : [value]; }, [value]); @@ -397,12 +403,12 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI ); }; diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx index 9ac52bf22..10b54784b 100644 --- a/src/renderer/features/albums/components/subsonic-album-filters.tsx +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -13,6 +13,7 @@ import { } from '/@/renderer/features/shared/components/multi-select-rows'; import { useCurrentServerId } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -37,7 +38,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte const isGenrePage = customFilters?.genreIds !== undefined; - const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } = + const { clear, query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } = useAlbumListFilters(); const albumArtistListQuery = useSuspenseQuery( @@ -215,7 +216,7 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte {toggleFilters.map((filter) => ( {filter.label} - + ))} {!disableArtistFilter && ( @@ -251,24 +252,28 @@ export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFilte 0)} hideControls={false} label={t('filter.fromYear', { postProcess: 'sentenceCase' })} max={5000} min={0} onChange={(e) => debouncedHandleMinYearFilter(e)} + value={query.minYear ?? undefined} /> 0)} hideControls={false} label={t('filter.toYear', { postProcess: 'sentenceCase' })} max={5000} min={0} onChange={(e) => debouncedHandleMaxYearFilter(e)} + value={query.maxYear ?? undefined} /> + + ); }; diff --git a/src/renderer/features/albums/hooks/use-album-list-filters.ts b/src/renderer/features/albums/hooks/use-album-list-filters.ts index 0ba91d80a..da07fd861 100644 --- a/src/renderer/features/albums/hooks/use-album-list-filters.ts +++ b/src/renderer/features/albums/hooks/use-album-list-filters.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; @@ -10,6 +10,7 @@ import { parseBooleanParam, parseCustomFiltersParam, parseIntParam, + setMultipleSearchParams, setSearchParam, } from '/@/renderer/utils/query-params'; import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; @@ -18,12 +19,9 @@ import { ItemListKey } from '/@/shared/types/types'; export const useAlbumListFilters = (listKey?: ItemListKey) => { const resolvedListKey = listKey ?? ItemListKey.ALBUM; - const { setSortBy, sortBy } = useSortByFilter( - AlbumListSort.NAME, - resolvedListKey, - ); + const { sortBy } = useSortByFilter(AlbumListSort.NAME, resolvedListKey); - const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey); + const { sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey); const { searchTerm, setSearchTerm } = useSearchTermFilter(''); @@ -74,12 +72,6 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => { [searchParams], ); - // Use a ref to track the latest custom filters to avoid stale state during batched updates - const customRef = useRef | undefined>(custom); - useEffect(() => { - customRef.current = custom; - }, [custom]); - const setGenreId = useCallback( (value: null | string[]) => { setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.ALBUM.GENRE_ID, value), { @@ -184,32 +176,27 @@ export const useAlbumListFilters = (listKey?: ItemListKey) => { ); const clear = useCallback(() => { - setAlbumArtist(null); - setCompilation(null); - setCustom(null); - setFavorite(null); - setGenreId(null); - setHasRating(null); - setMaxYear(null); - setMinYear(null); - setRecentlyPlayed(null); - setSearchTerm(null); - setSortBy(AlbumListSort.NAME); - setSortOrder(SortOrder.ASC); - }, [ - setAlbumArtist, - setCompilation, - setCustom, - setFavorite, - setGenreId, - setHasRating, - setMaxYear, - setMinYear, - setRecentlyPlayed, - setSearchTerm, - setSortBy, - setSortOrder, - ]); + setSearchParams( + (prev) => + setMultipleSearchParams( + prev, + { + [FILTER_KEYS.ALBUM._CUSTOM]: null, + [FILTER_KEYS.ALBUM.ARTIST_IDS]: null, + [FILTER_KEYS.ALBUM.COMPILATION]: null, + [FILTER_KEYS.ALBUM.FAVORITE]: null, + [FILTER_KEYS.ALBUM.GENRE_ID]: null, + [FILTER_KEYS.ALBUM.HAS_RATING]: null, + [FILTER_KEYS.ALBUM.MAX_YEAR]: null, + [FILTER_KEYS.ALBUM.MIN_YEAR]: null, + [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: null, + [FILTER_KEYS.SHARED.SEARCH_TERM]: null, + }, + new Set([FILTER_KEYS.ALBUM._CUSTOM]), + ), + { replace: true }, + ); + }, [setSearchParams]); const query = useMemo( () => ({ diff --git a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts index cec05928d..d0378c2c2 100644 --- a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts @@ -1,27 +1,32 @@ import { useCallback } from 'react'; +import { useSearchParams } from 'react-router'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; import { FILTER_KEYS } from '/@/renderer/features/shared/utils'; -import { AlbumArtistListSort, SortOrder } from '/@/shared/types/domain-types'; +import { setMultipleSearchParams } from '/@/renderer/utils/query-params'; +import { AlbumArtistListSort } from '/@/shared/types/domain-types'; import { ItemListKey } from '/@/shared/types/types'; export const useAlbumArtistListFilters = () => { - const { setSortBy, sortBy } = useSortByFilter( - null, - ItemListKey.ALBUM_ARTIST, - ); + const { sortBy } = useSortByFilter(null, ItemListKey.ALBUM_ARTIST); - const { setSortOrder, sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST); + const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST); const { searchTerm, setSearchTerm } = useSearchTermFilter(''); + const [, setSearchParams] = useSearchParams(); + const clear = useCallback(() => { - setSearchTerm(null); - setSortBy(AlbumArtistListSort.NAME); - setSortOrder(SortOrder.ASC); - }, [setSearchTerm, setSortBy, setSortOrder]); + setSearchParams( + (prev) => + setMultipleSearchParams(prev, { + [FILTER_KEYS.SHARED.SEARCH_TERM]: null, + }), + { replace: true }, + ); + }, [setSearchParams]); const query = { [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, diff --git a/src/renderer/features/songs/components/jellyfin-song-filters.tsx b/src/renderer/features/songs/components/jellyfin-song-filters.tsx index 7b163e50e..104ab45ba 100644 --- a/src/renderer/features/songs/components/jellyfin-song-filters.tsx +++ b/src/renderer/features/songs/components/jellyfin-song-filters.tsx @@ -15,6 +15,7 @@ import { import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { useCurrentServer } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -34,7 +35,7 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters const server = useCurrentServer(); const serverId = server.id; const { t } = useTranslation(); - const { query, setArtistIds, setCustom, setFavorite, setMaxYear, setMinYear } = + const { clear, query, setArtistIds, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters(); const { customFilters } = useListContext(); @@ -280,10 +281,10 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters {yesNoFilters.map((filter) => ( filter.onChange(e ? e === 'true' : undefined)} + value={filter.value ? filter.value.toString() : undefined} /> ))} {!disableArtistFilter && ( @@ -320,34 +321,38 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters debouncedHandleMinYearFilter(e)} required={!!query.minYear} + value={query.minYear ?? undefined} /> debouncedHandleMaxYearFilter(e)} required={!!query.minYear} + value={query.maxYear ?? undefined} /> {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( )} + + ); }; diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index 7646d5a58..30beefbb6 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -17,6 +17,7 @@ import { useCurrentServer, useCurrentServerId } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { titleCase } from '/@/renderer/utils'; import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -31,7 +32,7 @@ export const NavidromeSongFilters = () => { const { t } = useTranslation(); const server = useCurrentServer(); const serverId = server.id; - const { query, setArtistIds, setFavorite, setGenreId, setMaxYear, setMinYear } = + const { clear, query, setArtistIds, setFavorite, setGenreId, setMaxYear, setMinYear } = useSongListFilters(); const { customFilters } = useListContext(); @@ -252,11 +253,11 @@ export const NavidromeSongFilters = () => { { setFavorite(segmentValueToBoolean(value)); }} size="sm" + value={booleanToSegmentValue(query.favorite)} w="100%" /> @@ -284,15 +285,19 @@ export const NavidromeSongFilters = () => { /> )} debouncedHandleYearFilter(e)} + value={query.minYear ?? undefined} /> + + ); }; @@ -315,7 +320,7 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI [options], ); - const defaultValue = useMemo(() => { + const currentValue = useMemo(() => { if (!value) return []; return Array.isArray(value) ? value : [value]; }, [value]); @@ -335,12 +340,12 @@ const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterI ); }; diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx index cb0cfd787..7bca28087 100644 --- a/src/renderer/features/songs/components/subsonic-song-filters.tsx +++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx @@ -5,6 +5,7 @@ import { useListContext } from '/@/renderer/context/list-context'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { GenreMultiSelectRow } from '/@/renderer/features/shared/components/multi-select-rows'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; +import { Button } from '/@/shared/components/button/button'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -14,7 +15,7 @@ import { Text } from '/@/shared/components/text/text'; export const SubsonicSongFilters = () => { const { t } = useTranslation(); - const { query, setFavorite, setGenreId } = useSongListFilters(); + const { clear, query, setFavorite, setGenreId } = useSongListFilters(); const { customFilters } = useListContext(); @@ -72,7 +73,7 @@ export const SubsonicSongFilters = () => { {toggleFilters.map((filter) => ( {filter.label} - + ))} {!isGenrePage && ( @@ -90,6 +91,10 @@ export const SubsonicSongFilters = () => { /> )} + + ); }; diff --git a/src/renderer/features/songs/hooks/use-song-list-filters.ts b/src/renderer/features/songs/hooks/use-song-list-filters.ts index 39725ccc4..dfa6db5cd 100644 --- a/src/renderer/features/songs/hooks/use-song-list-filters.ts +++ b/src/renderer/features/songs/hooks/use-song-list-filters.ts @@ -11,6 +11,7 @@ import { parseCustomFiltersParam, parseIntParam, setJsonSearchParam, + setMultipleSearchParams, setSearchParam, } from '/@/renderer/utils/query-params'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; @@ -19,9 +20,9 @@ import { ItemListKey } from '/@/shared/types/types'; export const useSongListFilters = (listKey?: ItemListKey) => { const resolvedListKey = listKey ?? ItemListKey.SONG; - const { setSortBy, sortBy } = useSortByFilter(SongListSort.NAME, resolvedListKey); + const { sortBy } = useSortByFilter(SongListSort.NAME, resolvedListKey); - const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey); + const { sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey); const { searchTerm, setSearchTerm } = useSearchTermFilter(''); @@ -145,28 +146,25 @@ export const useSongListFilters = (listKey?: ItemListKey) => { ); const clear = useCallback(() => { - setAlbumIds(null); - setArtistIds(null); - setCustom(null); - setFavorite(null); - setGenreId(null); - setMaxYear(null); - setMinYear(null); - setSearchTerm(null); - setSortBy(SongListSort.NAME); - setSortOrder(SortOrder.ASC); - }, [ - setAlbumIds, - setArtistIds, - setCustom, - setFavorite, - setGenreId, - setMaxYear, - setMinYear, - setSearchTerm, - setSortBy, - setSortOrder, - ]); + setSearchParams( + (prev) => + setMultipleSearchParams( + prev, + { + [FILTER_KEYS.SHARED.SEARCH_TERM]: null, + [FILTER_KEYS.SONG._CUSTOM]: null, + [FILTER_KEYS.SONG.ALBUM_IDS]: null, + [FILTER_KEYS.SONG.ARTIST_IDS]: null, + [FILTER_KEYS.SONG.FAVORITE]: null, + [FILTER_KEYS.SONG.GENRE_ID]: null, + [FILTER_KEYS.SONG.MAX_YEAR]: null, + [FILTER_KEYS.SONG.MIN_YEAR]: null, + }, + new Set([FILTER_KEYS.SONG._CUSTOM]), + ), + { replace: true }, + ); + }, [setSearchParams]); const query = useMemo( () => ({ diff --git a/src/renderer/utils/query-params.ts b/src/renderer/utils/query-params.ts index 7762d9284..63a24c618 100644 --- a/src/renderer/utils/query-params.ts +++ b/src/renderer/utils/query-params.ts @@ -123,6 +123,45 @@ export const setJsonSearchParam = ( return newParams; }; +export const setMultipleSearchParams = ( + searchParams: URLSearchParams, + params: Record< + string, + boolean | null | number | Record | string | string[] | undefined + >, + jsonKeys?: Set, +): URLSearchParams => { + const newParams = new URLSearchParams(searchParams); + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) { + newParams.delete(key); + continue; + } + + if (jsonKeys?.has(key)) { + if (typeof value === 'object' && !Array.isArray(value)) { + newParams.set(key, JSON.stringify(value)); + } else { + newParams.delete(key); + } + } else { + if (Array.isArray(value)) { + newParams.delete(key); + value.forEach((v) => newParams.append(key, String(v))); + } else if (typeof value === 'boolean') { + newParams.set(key, String(value)); + } else if (typeof value === 'number') { + newParams.set(key, String(value)); + } else { + newParams.set(key, value as string); + } + } + } + + return newParams; +}; + /** * Parse custom filters from URLSearchParams with validation */