feat(search): wire type facettes in SearchPage (G4)

This commit is contained in:
senke 2026-02-20 16:55:32 +01:00
parent aeb941d41a
commit 72bc1a811d
5 changed files with 68 additions and 27 deletions

View file

@ -19,6 +19,8 @@ export function SearchPage() {
error,
clearSearch,
hasResults,
activeTab,
onTabChange,
} = useSearchPage();
return (
@ -43,7 +45,7 @@ export function SearchPage() {
) : !hasResults ? (
<SearchPageEmpty />
) : results ? (
<SearchPageResults results={results} query={query} />
<SearchPageResults results={results} query={query} activeTab={activeTab} onTabChange={onTabChange} />
) : null}
</div>
);

View file

@ -10,9 +10,11 @@ import { highlightMatch } from '../../utils/highlightMatch';
interface SearchPageResultsProps {
results: SearchResults;
query?: string;
activeTab?: string;
onTabChange?: (value: string) => void;
}
export function SearchPageResults({ results, query = '' }: SearchPageResultsProps) {
export function SearchPageResults({ results, query = '', activeTab = 'all', onTabChange }: SearchPageResultsProps) {
const navigate = useNavigate();
const totalResults =
@ -25,7 +27,7 @@ export function SearchPageResults({ results, query = '' }: SearchPageResultsProp
<div role="status" className="sr-only">
{totalResults} result{totalResults !== 1 ? 's' : ''} found
</div>
<Tabs defaultValue="all" className="w-full">
<Tabs value={activeTab} onValueChange={onTabChange ?? (() => {})} className="w-full">
<TabsList className="bg-transparent border-b border-white/10 w-full justify-start h-auto p-0 gap-8 mb-8">
<TabsTrigger
value="all"

View file

@ -4,12 +4,23 @@ import { searchApi } from '@/services/api/search';
import { SearchResults } from '@/types/search';
import { useDebounce } from '@/hooks/useDebounce';
export type SearchActiveTab = 'all' | 'track' | 'user' | 'playlist';
function tabToTypes(tab: SearchActiveTab): string[] {
if (tab === 'all') return [];
return [tab];
}
export function useSearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryParam = searchParams.get('q') ?? '';
const typeParam = searchParams.get('type') ?? 'all';
const [query, setQuery] = useState(queryParam);
const debouncedQuery = useDebounce(query, 500);
const [activeTab, setActiveTab] = useState<SearchActiveTab>(
typeParam === 'track' || typeParam === 'user' || typeParam === 'playlist' ? typeParam : 'all'
);
const [results, setResults] = useState<SearchResults | null>(null);
const [isLoading, setIsLoading] = useState(false);
@ -20,15 +31,22 @@ export function useSearchPage() {
}, [queryParam]);
useEffect(() => {
const t = typeParam === 'track' || typeParam === 'user' || typeParam === 'playlist' ? typeParam : 'all';
setActiveTab(t);
}, [typeParam]);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults(null);
setSearchParams({}, { replace: true });
return;
}
const performSearch = async () => {
if (!debouncedQuery.trim()) {
setResults(null);
return;
}
setIsLoading(true);
setError(null);
try {
const data = await searchApi.search(debouncedQuery);
const types = tabToTypes(activeTab);
const data = await searchApi.search(debouncedQuery, types);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Search signal interrupted.'));
@ -37,14 +55,10 @@ export function useSearchPage() {
}
};
performSearch();
if (debouncedQuery !== queryParam) {
if (debouncedQuery) {
setSearchParams({ q: debouncedQuery }, { replace: true });
} else {
setSearchParams({}, { replace: true });
}
}
}, [debouncedQuery, setSearchParams, queryParam]);
const nextParams: Record<string, string> = { q: debouncedQuery };
if (activeTab !== 'all') nextParams.type = activeTab;
setSearchParams(nextParams, { replace: true });
}, [debouncedQuery, activeTab, setSearchParams]);
const clearSearch = () => {
setQuery('');
@ -52,6 +66,18 @@ export function useSearchPage() {
setResults(null);
};
const handleTabChange = (tab: string) => {
const map: Record<string, SearchActiveTab> = {
all: 'all',
tracks: 'track',
artists: 'user',
playlists: 'playlist',
};
setActiveTab(map[tab] ?? 'all');
};
const activeTabValue = activeTab === 'track' ? 'tracks' : activeTab === 'user' ? 'artists' : activeTab === 'playlist' ? 'playlists' : 'all';
const hasResults =
!!results &&
(results.tracks.length > 0 || results.artists.length > 0 || results.playlists.length > 0);
@ -64,5 +90,7 @@ export function useSearchPage() {
error,
clearSearch,
hasResults,
activeTab: activeTabValue,
onTabChange: handleTabChange,
};
}

View file

@ -112,17 +112,22 @@ export const handlersMisc = [
});
}),
http.get('*/api/v1/search', () => {
http.get('*/api/v1/search', ({ request }) => {
const url = new URL(request.url);
const types = url.searchParams.getAll('type');
const all = types.length === 0;
const tracks = all || types.includes('track')
? [{ id: 'track-1', title: 'Neon Signal', artist: 'Void Producer', cover_art_path: 'https://picsum.photos/200', created_at: '2024-01-15T12:00:00Z' }, { id: 'track-2', title: 'Deep Frequency', artist: 'Echo Artist', created_at: '2024-01-14T10:00:00Z' }]
: [];
const artists = all || types.includes('user')
? [{ id: 'artist-1', username: 'ProducerOne', avatar_url: 'https://i.pravatar.cc/150?u=producer1', followers_count: 120 }]
: [];
const playlists = all || types.includes('playlist')
? [{ id: 'playlist-1', title: 'Curated Mix', description: 'Hand-picked tracks', cover_url: 'https://picsum.photos/300' }]
: [];
return HttpResponse.json({
success: true,
data: {
tracks: [
{ id: 'track-1', title: 'Neon Signal', artist: 'Void Producer', cover_art_path: 'https://picsum.photos/200', created_at: '2024-01-15T12:00:00Z' },
{ id: 'track-2', title: 'Deep Frequency', artist: 'Echo Artist', created_at: '2024-01-14T10:00:00Z' },
],
artists: [{ id: 'artist-1', username: 'ProducerOne', avatar_url: 'https://i.pravatar.cc/150?u=producer1', followers_count: 120 }],
playlists: [{ id: 'playlist-1', title: 'Curated Mix', description: 'Hand-picked tracks', cover_url: 'https://picsum.photos/300' }],
},
data: { tracks, artists, playlists },
});
}),

View file

@ -2,9 +2,13 @@ import { apiClient } from '@/services/api/client';
import { SearchResults } from '@/types/search';
export const searchApi = {
search: async (query: string): Promise<SearchResults> => {
search: async (query: string, types?: string[]): Promise<SearchResults> => {
const params: Record<string, string | string[]> = { q: query };
if (types && types.length > 0) {
params.type = types;
}
const response = await apiClient.get<SearchResults>(`/search`, {
params: { q: query },
params,
});
return response.data;
},