refactor(search): extract advanced-search filter panel into a component

This commit is contained in:
Psychotoxical
2026-06-30 23:59:42 +02:00
parent 97bd7afed1
commit 386742e2f2
3 changed files with 369 additions and 266 deletions
@@ -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<SetStateAction<string>>;
genre: string;
setGenre: Dispatch<SetStateAction<string>>;
genres: SubsonicGenre[];
yearFrom: string;
setYearFrom: Dispatch<SetStateAction<string>>;
yearTo: string;
setYearTo: Dispatch<SetStateAction<string>>;
bpmFrom: string;
setBpmFrom: Dispatch<SetStateAction<string>>;
bpmTo: string;
setBpmTo: Dispatch<SetStateAction<string>>;
moodGroup: string;
setMoodGroup: Dispatch<SetStateAction<string>>;
losslessOnly: boolean;
setLosslessOnly: Dispatch<SetStateAction<boolean>>;
resultType: ResultType;
setResultType: Dispatch<SetStateAction<ResultType>>;
starredOnly: boolean;
setStarredOnly: Dispatch<SetStateAction<boolean>>;
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 (
<form onSubmit={onSubmit}>
<div className="settings-card" style={{ padding: '1.25rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
{/* Row 1: Search term */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedSearchTerm')}
</span>
<input
className="input"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('search.advancedSearchPlaceholder')}
style={{ flex: 1 }}
autoFocus={autoFocusQuery}
/>
</div>
{/* Row 2: Genre + Year */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
onChange={setGenre}
/>
</div>
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginLeft: '0.75rem', flexShrink: 0 }}>
{t('search.advancedYear')}
</span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 96 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 96 }}
/>
</div>
{/* Row 3: BPM (tag + measured enrichment) */}
{indexEnabled && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedBpm')}
</span>
<input
className="input"
type="number"
min={20}
max={999}
value={bpmFrom}
onChange={e => 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 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={20}
max={999}
value={bpmTo}
onChange={e => 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 && (
<button
type="button"
className="btn btn-ghost"
onClick={clearBpmFilter}
style={{
padding: '0.3rem 0.55rem',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
fontSize: '0.8rem',
flexShrink: 0,
}}
>
<X size={13} />
{t('search.advancedBpmClear')}
</button>
)}
</div>
)}
{/* Lossless — suffix allowlist (FLAC, WAV, …) */}
{indexEnabled && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedLossless')}
</span>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.45rem',
fontSize: 13,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={losslessOnly}
onChange={e => setLosslessOnly(e.target.checked)}
/>
{t('search.advancedLosslessOnly')}
</label>
</div>
)}
{/* Mood — hidden while oximedia mood analysis is disabled */}
{indexEnabled && MOOD_UI_ENABLED && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedMoodGroup')}
</span>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={moodGroup}
options={moodSelectOptions}
onChange={setMoodGroup}
/>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('search.advancedMoodLocalNote')}
</span>
</div>
)}
{/* Row 4: Result type + Search button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
{!trackFilterActive && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginRight: '0.15rem' }}>
{t('search.scopeRowLabel')}
</span>
)}
{!trackFilterActive && typeOptions.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => setResultType(opt.id)}
{...tooltipAttrs(opt.tooltip)}
>
{opt.label}
</button>
))}
<StarFilterButton size="small" active={starredOnly} onChange={setStarredOnly} />
</div>
<button
className="btn btn-primary"
type="submit"
disabled={loading}
style={{ minWidth: 100 }}
>
{loading
? <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
: t('search.advancedSearch')
}
</button>
</div>
</div>
</div>
</form>
);
}
+33 -266
View File
@@ -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<typeof useNavigationType>,
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 (
<div
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
@@ -937,210 +878,36 @@ export default function SearchBrowsePage() {
</div>
{showAdvancedPanel && (
<>
{/* ── Filter panel ──────────────────────────────────────── */}
<form onSubmit={handleSubmit}>
<div className="settings-card" style={{ padding: '1.25rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
{/* Row 1: Search term */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedSearchTerm')}
</span>
<input
className="input"
type="text"
value={query}
onChange={e => 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}
/>
</div>
{/* Row 2: Genre + Year */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
onChange={setGenre}
/>
</div>
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginLeft: '0.75rem', flexShrink: 0 }}>
{t('search.advancedYear')}
</span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 96 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 96 }}
/>
</div>
{/* Row 3: BPM (tag + measured enrichment) */}
{indexEnabled && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedBpm')}
</span>
<input
className="input"
type="number"
min={20}
max={999}
value={bpmFrom}
onChange={e => 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 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={20}
max={999}
value={bpmTo}
onChange={e => 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 && (
<button
type="button"
className="btn btn-ghost"
onClick={clearBpmFilter}
style={{
padding: '0.3rem 0.55rem',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
fontSize: '0.8rem',
flexShrink: 0,
}}
>
<X size={13} />
{t('search.advancedBpmClear')}
</button>
)}
</div>
)}
{/* Lossless — suffix allowlist (FLAC, WAV, …) */}
{indexEnabled && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedLossless')}
</span>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '0.45rem',
fontSize: 13,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={losslessOnly}
onChange={e => setLosslessOnly(e.target.checked)}
/>
{t('search.advancedLosslessOnly')}
</label>
</div>
)}
{/* Mood — hidden while oximedia mood analysis is disabled */}
{indexEnabled && MOOD_UI_ENABLED && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedMoodGroup')}
</span>
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
<CustomSelect
value={moodGroup}
options={moodSelectOptions}
onChange={setMoodGroup}
/>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('search.advancedMoodLocalNote')}
</span>
</div>
)}
{/* Row 4: Result type + Search button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
{!trackFilterActive && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginRight: '0.15rem' }}>
{t('search.scopeRowLabel')}
</span>
)}
{!trackFilterActive && typeOptions.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => setResultType(opt.id)}
{...tooltipAttrs(opt.tooltip)}
>
{opt.label}
</button>
))}
<StarFilterButton size="small" active={starredOnly} onChange={setStarredOnly} />
</div>
<button
className="btn btn-primary"
type="submit"
disabled={loading}
style={{ minWidth: 100 }}
>
{loading
? <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
: t('search.advancedSearch')
}
</button>
</div>
</div>
</div>
</form>
</>
<AdvancedSearchFilterPanel
query={query}
setQuery={setQuery}
genre={genre}
setGenre={setGenre}
genres={genres}
yearFrom={yearFrom}
setYearFrom={setYearFrom}
yearTo={yearTo}
setYearTo={setYearTo}
bpmFrom={bpmFrom}
setBpmFrom={setBpmFrom}
bpmTo={bpmTo}
setBpmTo={setBpmTo}
moodGroup={moodGroup}
setMoodGroup={setMoodGroup}
losslessOnly={losslessOnly}
setLosslessOnly={setLosslessOnly}
resultType={resultType}
setResultType={setResultType}
starredOnly={starredOnly}
setStarredOnly={setStarredOnly}
trackFilterActive={trackFilterActive}
indexEnabled={indexEnabled}
loading={loading}
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
autoFocusQuery={!skipSearchAutoFocusRef.current}
onSubmit={handleSubmit}
/>
)}
{/* ── Results ───────────────────────────────────────────── */}
+24
View File
@@ -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[];
}