feat(search): wire type facettes in SearchPage (G4)
This commit is contained in:
parent
aeb941d41a
commit
72bc1a811d
5 changed files with 68 additions and 27 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue