diff --git a/src/features/search/components/AdvancedSearchFilterPanel.tsx b/src/features/search/components/AdvancedSearchFilterPanel.tsx new file mode 100644 index 00000000..bb5976e8 --- /dev/null +++ b/src/features/search/components/AdvancedSearchFilterPanel.tsx @@ -0,0 +1,312 @@ +import React, { useMemo, type Dispatch, type SetStateAction } from 'react'; +import { X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import CustomSelect from '@/ui/CustomSelect'; +import StarFilterButton from '@/ui/StarFilterButton'; +import { tooltipAttrs } from '@/ui/tooltipAttrs'; +import { MOOD_GROUP_IDS } from '@/config/moodGroups'; +import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '@/lib/library/trackEnrichment'; +import type { SubsonicGenre } from '@/lib/api/subsonicTypes'; +import type { ResultType } from '@/features/search/searchBrowseTypes'; + +const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED; + +function parseBpmInput(raw: string): number | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const n = parseInt(trimmed, 10); + return Number.isFinite(n) ? n : null; +} + +interface AdvancedSearchFilterPanelProps { + query: string; + setQuery: Dispatch>; + genre: string; + setGenre: Dispatch>; + genres: SubsonicGenre[]; + yearFrom: string; + setYearFrom: Dispatch>; + yearTo: string; + setYearTo: Dispatch>; + bpmFrom: string; + setBpmFrom: Dispatch>; + bpmTo: string; + setBpmTo: Dispatch>; + moodGroup: string; + setMoodGroup: Dispatch>; + losslessOnly: boolean; + setLosslessOnly: Dispatch>; + resultType: ResultType; + setResultType: Dispatch>; + starredOnly: boolean; + setStarredOnly: Dispatch>; + trackFilterActive: boolean; + indexEnabled: boolean; + loading: boolean; + autoFocusQuery: boolean; + onSubmit: (e?: React.FormEvent) => void; +} + +/** The advanced-search filter form (term, genre, year, BPM, lossless, mood, result-scope). */ +export default function AdvancedSearchFilterPanel({ + query, + setQuery, + genre, + setGenre, + genres, + yearFrom, + setYearFrom, + yearTo, + setYearTo, + bpmFrom, + setBpmFrom, + bpmTo, + setBpmTo, + moodGroup, + setMoodGroup, + losslessOnly, + setLosslessOnly, + resultType, + setResultType, + starredOnly, + setStarredOnly, + trackFilterActive, + indexEnabled, + loading, + autoFocusQuery, + onSubmit, +}: AdvancedSearchFilterPanelProps) { + const { t } = useTranslation(); + + const bpmFilterDraftActive = !!(bpmFrom || bpmTo); + + const clearBpmFilter = () => { + setBpmFrom(''); + setBpmTo(''); + }; + + const typeOptions: { id: ResultType; label: string; tooltip: string }[] = [ + { id: 'all', label: t('search.advancedAll'), tooltip: t('search.scopeAllTooltip') }, + { id: 'artists', label: t('search.artists'), tooltip: t('search.scopeArtistsChipTooltip') }, + { id: 'albums', label: t('search.albums'), tooltip: t('search.scopeAlbumsChipTooltip') }, + { id: 'songs', label: t('search.songs'), tooltip: t('search.scopeSongsChipTooltip') }, + ]; + + const genreSelectOptions = [ + { value: '', label: t('search.advancedAllGenres') }, + ...genres.map(g => ({ value: g.value, label: g.value })), + ]; + + const moodSelectOptions = useMemo( + () => [ + { value: '', label: t('search.advancedAllMoods') }, + ...MOOD_GROUP_IDS.map(id => ({ + value: id, + label: t(`search.moodGroups.${id}`), + })), + ], + [t], + ); + + return ( +
+
+
+ + {/* Row 1: Search term */} +
+ + {t('search.advancedSearchTerm')} + + setQuery(e.target.value)} + placeholder={t('search.advancedSearchPlaceholder')} + style={{ flex: 1 }} + autoFocus={autoFocusQuery} + /> +
+ + {/* Row 2: Genre + Year */} +
+ + {t('search.advancedGenre')} + +
+ +
+ + + {t('search.advancedYear')} + + setYearFrom(e.target.value)} + placeholder={t('search.advancedYearFrom')} + style={{ width: 96 }} + /> + + setYearTo(e.target.value)} + placeholder={t('search.advancedYearTo')} + style={{ width: 96 }} + /> +
+ + {/* Row 3: BPM (tag + measured enrichment) */} + {indexEnabled && ( +
+ + {t('search.advancedBpm')} + + setBpmFrom(e.target.value)} + onBlur={e => { + const from = parseBpmInput(e.target.value); + const to = parseBpmInput(bpmTo); + if (from != null && to != null && from > to) setBpmTo(''); + }} + placeholder={t('search.advancedYearFrom')} + style={{ width: 96 }} + /> + + setBpmTo(e.target.value)} + onBlur={e => { + const to = parseBpmInput(e.target.value); + const from = parseBpmInput(bpmFrom); + if (from != null && to != null && to < from) setBpmFrom(''); + }} + placeholder={t('search.advancedYearTo')} + style={{ width: 96 }} + /> + {bpmFilterDraftActive && ( + + )} +
+ )} + + {/* Lossless — suffix allowlist (FLAC, WAV, …) */} + {indexEnabled && ( +
+ + {t('search.advancedLossless')} + + +
+ )} + + {/* Mood — hidden while oximedia mood analysis is disabled */} + {indexEnabled && MOOD_UI_ENABLED && ( +
+ + {t('search.advancedMoodGroup')} + +
+ +
+ + {t('search.advancedMoodLocalNote')} + +
+ )} + + {/* Row 4: Result type + Search button */} +
+
+ {!trackFilterActive && ( + + {t('search.scopeRowLabel')} + + )} + {!trackFilterActive && typeOptions.map(opt => ( + + ))} + +
+ +
+
+
+
+ ); +} diff --git a/src/features/search/pages/SearchBrowsePage.tsx b/src/features/search/pages/SearchBrowsePage.tsx index 70cb120d..556e6dea 100644 --- a/src/features/search/pages/SearchBrowsePage.tsx +++ b/src/features/search/pages/SearchBrowsePage.tsx @@ -2,16 +2,15 @@ import { getGenres, getAlbumsByGenre } from '@/lib/api/subsonicGenres'; import { search, searchSongsPaged } from '@/lib/api/subsonicSearch'; import { getRandomSongs } from '@/lib/api/subsonicLibrary'; import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes'; +import type { ResultType, SearchOpts, Results } from '@/features/search/searchBrowseTypes'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useNavigationType, useSearchParams } from 'react-router-dom'; -import { SlidersVertical, Search, X } from 'lucide-react'; +import { SlidersVertical, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { AlbumRow } from '@/features/album'; import { ArtistRow } from '@/features/artist'; import PagedSongList from '@/features/search/components/PagedSongList'; -import CustomSelect from '@/ui/CustomSelect'; -import StarFilterButton from '@/ui/StarFilterButton'; -import { tooltipAttrs } from '@/ui/tooltipAttrs'; +import AdvancedSearchFilterPanel from '@/features/search/components/AdvancedSearchFilterPanel'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll'; import { useAuthStore } from '@/store/authStore'; import { usePlayerStore } from '@/features/playback/store/playerStore'; @@ -56,7 +55,6 @@ import { runNetworkBrowseFullSearch, } from '@/lib/library/browseTextSearch'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { MOOD_GROUP_IDS } from '@/config/moodGroups'; import { usePerfProbeFlags } from '@/lib/perf/perfFlags'; import { useSongBrowseList, type SongBrowseListRestore } from '@/features/search/hooks/useSongBrowseList'; import TracksPageChrome from '@/features/search/components/TracksPageChrome'; @@ -68,33 +66,6 @@ import { const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED; -type ResultType = 'all' | 'artists' | 'albums' | 'songs'; - -interface SearchOpts { - query: string; - genre: string; - yearFrom: string; - yearTo: string; - bpmFrom: string; - bpmTo: string; - moodGroup: string; - losslessOnly: boolean; - resultType: ResultType; -} - -interface Results { - artists: SubsonicArtist[]; - albums: SubsonicAlbum[]; - songs: SubsonicSong[]; -} - -function parseBpmInput(raw: string): number | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - const n = parseInt(trimmed, 10); - return Number.isFinite(n) ? n : null; -} - function peekAdvancedSearchRestoreStash( navigationType: ReturnType, locationState: unknown, @@ -841,13 +812,6 @@ export default function SearchBrowsePage() { const trackFilterActive = (MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo); - const bpmFilterDraftActive = !!(bpmFrom || bpmTo); - - const clearBpmFilter = () => { - setBpmFrom(''); - setBpmTo(''); - }; - const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); const effectiveType = trackFilterActive ? 'songs' : resultType; @@ -864,29 +828,6 @@ export default function SearchBrowsePage() { }); }; - const typeOptions: { id: ResultType; label: string; tooltip: string }[] = [ - { id: 'all', label: t('search.advancedAll'), tooltip: t('search.scopeAllTooltip') }, - { id: 'artists', label: t('search.artists'), tooltip: t('search.scopeArtistsChipTooltip') }, - { id: 'albums', label: t('search.albums'), tooltip: t('search.scopeAlbumsChipTooltip') }, - { id: 'songs', label: t('search.songs'), tooltip: t('search.scopeSongsChipTooltip') }, - ]; - - const genreSelectOptions = [ - { value: '', label: t('search.advancedAllGenres') }, - ...genres.map(g => ({ value: g.value, label: g.value })), - ]; - - const moodSelectOptions = useMemo( - () => [ - { value: '', label: t('search.advancedAllMoods') }, - ...MOOD_GROUP_IDS.map(id => ({ - value: id, - label: t(`search.moodGroups.${id}`), - })), - ], - [t], - ); - return (
{showAdvancedPanel && ( - <> - {/* ── Filter panel ──────────────────────────────────────── */} -
-
-
- - {/* Row 1: Search term */} -
- - {t('search.advancedSearchTerm')} - - setQuery(e.target.value)} - placeholder={t('search.advancedSearchPlaceholder')} - style={{ flex: 1 }} - // React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output. - // eslint-disable-next-line react-hooks/refs - autoFocus={!skipSearchAutoFocusRef.current} - /> -
- - {/* Row 2: Genre + Year */} -
- - {t('search.advancedGenre')} - -
- -
- - - {t('search.advancedYear')} - - setYearFrom(e.target.value)} - placeholder={t('search.advancedYearFrom')} - style={{ width: 96 }} - /> - - setYearTo(e.target.value)} - placeholder={t('search.advancedYearTo')} - style={{ width: 96 }} - /> -
- - {/* Row 3: BPM (tag + measured enrichment) */} - {indexEnabled && ( -
- - {t('search.advancedBpm')} - - setBpmFrom(e.target.value)} - onBlur={e => { - const from = parseBpmInput(e.target.value); - const to = parseBpmInput(bpmTo); - if (from != null && to != null && from > to) setBpmTo(''); - }} - placeholder={t('search.advancedYearFrom')} - style={{ width: 96 }} - /> - - setBpmTo(e.target.value)} - onBlur={e => { - const to = parseBpmInput(e.target.value); - const from = parseBpmInput(bpmFrom); - if (from != null && to != null && to < from) setBpmFrom(''); - }} - placeholder={t('search.advancedYearTo')} - style={{ width: 96 }} - /> - {bpmFilterDraftActive && ( - - )} -
- )} - - {/* Lossless — suffix allowlist (FLAC, WAV, …) */} - {indexEnabled && ( -
- - {t('search.advancedLossless')} - - -
- )} - - {/* Mood — hidden while oximedia mood analysis is disabled */} - {indexEnabled && MOOD_UI_ENABLED && ( -
- - {t('search.advancedMoodGroup')} - -
- -
- - {t('search.advancedMoodLocalNote')} - -
- )} - - {/* Row 4: Result type + Search button */} -
-
- {!trackFilterActive && ( - - {t('search.scopeRowLabel')} - - )} - {!trackFilterActive && typeOptions.map(opt => ( - - ))} - -
- -
-
-
-
- + )} {/* ── Results ───────────────────────────────────────────── */} diff --git a/src/features/search/searchBrowseTypes.ts b/src/features/search/searchBrowseTypes.ts new file mode 100644 index 00000000..91336014 --- /dev/null +++ b/src/features/search/searchBrowseTypes.ts @@ -0,0 +1,24 @@ +import type { SubsonicArtist, SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes'; + +/** Result-scope filter for the advanced/basic search shell. */ +export type ResultType = 'all' | 'artists' | 'albums' | 'songs'; + +/** The full filter snapshot a search run executes against. */ +export interface SearchOpts { + query: string; + genre: string; + yearFrom: string; + yearTo: string; + bpmFrom: string; + bpmTo: string; + moodGroup: string; + losslessOnly: boolean; + resultType: ResultType; +} + +/** Combined artist/album/song result set rendered by the search shell. */ +export interface Results { + artists: SubsonicArtist[]; + albums: SubsonicAlbum[]; + songs: SubsonicSong[]; +}