mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(search): extract advanced-search filter panel into a component
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────── */}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user