refactor(search): extract LiveSearch results dropdown into a component

This commit is contained in:
Psychotoxical
2026-06-30 23:41:07 +02:00
parent 16e014ca28
commit 0c1a9e249e
2 changed files with 218 additions and 166 deletions
+11 -166
View File
@@ -23,21 +23,15 @@ import {
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '@/features/album';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { Search, TextSearch } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { albumArtistDisplayName } from '@/features/album';
import {
LiveSearchAlbumThumb,
LiveSearchSongThumb,
LiveSearchArtistThumb,
} from '@/features/search/components/liveSearchResultThumbs';
import { useLiveSearchHeaderCollapse } from '@/features/search/hooks/useLiveSearchHeaderCollapse';
import LiveSearchDropdown, { type LiveSearchSource } from '@/features/search/components/LiveSearchDropdown';
import { showToast } from '@/lib/dom/toast';
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
import {
LiveSearchScopeBadge,
LiveSearchScopeGhostBadge,
@@ -55,8 +49,6 @@ import {
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
export default function LiveSearch() {
const { t } = useTranslation();
const query = useLiveSearchScopeStore(s => s.query);
@@ -79,10 +71,7 @@ export default function LiveSearch() {
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
const ctxType = usePlayerStore(state => state.contextMenu.type);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -366,10 +355,6 @@ export default function LiveSearch() {
return () => document.removeEventListener('mousedown', handler);
}, [ctxIsOpen]);
const hasResults =
!!share.shareMatch ||
(results && (results.artists.length || results.albums.length || results.songs.length));
// Flat list of all navigable items for keyboard nav
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
{
@@ -523,155 +508,15 @@ export default function LiveSearch() {
</div>
{open && !isLiveSearchDropdownBlocked(scope) && (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{searchSource && !share.shareMatch && (
<div
className={`live-search-source live-search-source--${searchSource}`}
data-tooltip={t(
searchSource === 'local'
? 'search.localIndexBadgeTooltip'
: 'search.networkSearchBadgeTooltip',
)}
data-tooltip-pos="bottom"
>
{searchSource === 'local' ? (
<Database size={12} aria-hidden />
) : (
<Globe size={12} aria-hidden />
)}
<span>
{t(
searchSource === 'local'
? 'search.localIndexBadge'
: 'search.networkSearchBadge',
)}
</span>
</div>
)}
{!hasResults && !loading && (
<div className="search-empty">{t('search.noResults', { query: query.trim() })}</div>
)}
{share.shareMatch && (
<ShareSearchResults
variant="desktop"
shareMatch={share.shareMatch}
shareServerLabel={share.shareServerLabel}
shareCoverServer={share.shareCoverServer}
activeIndex={activeIndex}
shareQueueBusy={share.shareQueueBusy}
onEnqueue={() => void share.enqueueShareMatch()}
onOpenAlbum={share.openShareAlbum}
onOpenArtist={share.openShareArtist}
onOpenComposer={share.openShareComposer}
onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)}
shareTrackSong={share.shareTrackSong}
shareTrackResolving={share.shareTrackResolving}
shareTrackUnavailable={share.shareTrackUnavailable}
shareAlbum={share.shareAlbum}
shareAlbumResolving={share.shareAlbumResolving}
shareAlbumUnavailable={share.shareAlbumUnavailable}
shareArtist={share.shareArtist}
shareArtistResolving={share.shareArtistResolving}
shareArtistUnavailable={share.shareArtistUnavailable}
shareComposer={share.shareComposer}
shareComposerResolving={share.shareComposerResolving}
shareComposerUnavailable={share.shareComposerUnavailable}
/>
)}
{(() => {
if (share.shareMatch) return null;
let idx = 0;
return <>
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchArtistThumb artist={a} />
<div>
<div className="search-result-name">{a.name}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}
onContextMenu={(e) => {
e.preventDefault();
// Keep the dropdown open — context menu portal renders above it,
// and closing here would yank the list out from under the user.
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchSongThumb song={s} />
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
</div>
</button>
);
})}
</div>
) : null}
</>;
})()}
</div>
<LiveSearchDropdown
dropdownRef={dropdownRef}
results={results}
searchSource={searchSource}
activeIndex={activeIndex}
loading={loading}
share={share}
setOpen={setOpen}
/>
)}
</div>
);
@@ -0,0 +1,207 @@
import { type RefObject } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Users, Disc3, Music, Database, Globe } from 'lucide-react';
import { useNavigateToAlbum, albumArtistDisplayName } from '@/features/album';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
import type { SearchResults } from '@/lib/api/subsonicTypes';
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
import {
LiveSearchAlbumThumb,
LiveSearchSongThumb,
LiveSearchArtistThumb,
} from '@/features/search/components/liveSearchResultThumbs';
import type { useShareSearch } from '@/features/search/hooks/useShareSearch';
export type LiveSearchSource = 'local' | 'network';
interface LiveSearchDropdownProps {
dropdownRef: RefObject<HTMLDivElement | null>;
results: SearchResults | null;
searchSource: LiveSearchSource | null;
activeIndex: number;
loading: boolean;
share: ReturnType<typeof useShareSearch>;
setOpen: (open: boolean) => void;
}
/** Live-search results overlay: source badge, share match, and the artist/album/song sections. */
export default function LiveSearchDropdown({
dropdownRef,
results,
searchSource,
activeIndex,
loading,
share,
setOpen,
}: LiveSearchDropdownProps) {
const { t } = useTranslation();
const query = useLiveSearchScopeStore(s => s.query);
const setQuery = useLiveSearchScopeStore(s => s.setQuery);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id);
const ctxType = usePlayerStore(state => state.contextMenu.type);
const hasResults =
!!share.shareMatch ||
(results && (results.artists.length || results.albums.length || results.songs.length));
return (
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{searchSource && !share.shareMatch && (
<div
className={`live-search-source live-search-source--${searchSource}`}
data-tooltip={t(
searchSource === 'local'
? 'search.localIndexBadgeTooltip'
: 'search.networkSearchBadgeTooltip',
)}
data-tooltip-pos="bottom"
>
{searchSource === 'local' ? (
<Database size={12} aria-hidden />
) : (
<Globe size={12} aria-hidden />
)}
<span>
{t(
searchSource === 'local'
? 'search.localIndexBadge'
: 'search.networkSearchBadge',
)}
</span>
</div>
)}
{!hasResults && !loading && (
<div className="search-empty">{t('search.noResults', { query: query.trim() })}</div>
)}
{share.shareMatch && (
<ShareSearchResults
variant="desktop"
shareMatch={share.shareMatch}
shareServerLabel={share.shareServerLabel}
shareCoverServer={share.shareCoverServer}
activeIndex={activeIndex}
shareQueueBusy={share.shareQueueBusy}
onEnqueue={() => void share.enqueueShareMatch()}
onOpenAlbum={share.openShareAlbum}
onOpenArtist={share.openShareArtist}
onOpenComposer={share.openShareComposer}
onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)}
shareTrackSong={share.shareTrackSong}
shareTrackResolving={share.shareTrackResolving}
shareTrackUnavailable={share.shareTrackUnavailable}
shareAlbum={share.shareAlbum}
shareAlbumResolving={share.shareAlbumResolving}
shareAlbumUnavailable={share.shareAlbumUnavailable}
shareArtist={share.shareArtist}
shareArtistResolving={share.shareArtistResolving}
shareArtistUnavailable={share.shareArtistUnavailable}
shareComposer={share.shareComposer}
shareComposerResolving={share.shareComposerResolving}
shareComposerUnavailable={share.shareComposerUnavailable}
/>
)}
{(() => {
if (share.shareMatch) return null;
let idx = 0;
return <>
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchArtistThumb artist={a} />
<div>
<div className="search-result-name">{a.name}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> {t('search.albums')}</div>
{results.albums.map(a => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
</div>
</button>
);
})}
</div>
) : null}
{results?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery('');
}}
onContextMenu={(e) => {
e.preventDefault();
// Keep the dropdown open — context menu portal renders above it,
// and closing here would yank the list out from under the user.
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}>
<LiveSearchSongThumb song={s} />
<div>
<div className="search-result-name">{s.title}</div>
<div className="search-result-sub">{s.artist} · {s.album}</div>
</div>
</button>
);
})}
</div>
) : null}
</>;
})()}
</div>
);
}