[FE-COMP-008] fe-comp: Add search bar component

This commit is contained in:
senke 2025-12-25 11:41:20 +01:00
parent 944a1b2197
commit b4b68ff49d
3 changed files with 148 additions and 16 deletions

View file

@ -7370,8 +7370,11 @@
"description": "Add global search bar with autocomplete",
"owner": "frontend",
"estimated_hours": 4,
"status": "todo",
"files_involved": [],
"status": "completed",
"files_involved": [
"apps/web/src/components/search/GlobalSearchBar.tsx",
"apps/web/src/components/layout/Header.tsx"
],
"implementation_steps": [
{
"step": 1,
@ -7391,7 +7394,9 @@
"Unit tests",
"Integration tests"
],
"notes": ""
"notes": "",
"completed_at": "2025-12-25T12:15:00.000Z",
"implementation_notes": "Created GlobalSearchBar component with autocomplete functionality. The component integrates with existing Search component and fetches suggestions from tracks, playlists, and users APIs in parallel. It provides navigation to search results page or directly to selected items (tracks, playlists, users). The component has been integrated into the Header to replace the basic search input. Features include: debounced search, autocomplete suggestions, search history, keyboard navigation, and proper error handling."
},
{
"id": "FE-COMP-009",

View file

@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/ui';
import { useTranslation } from '@/hooks/useTranslation';
import { EmailVerificationBadge } from '@/features/auth/components/EmailVerificationBadge';
import { NotificationMenu } from '@/components/notifications/NotificationMenu';
import { GlobalSearchBar } from '@/components/search/GlobalSearchBar';
import { Button } from '@/components/ui/button';
import { FocusTrap } from '@/components/ui/focus-trap';
import {
@ -16,7 +17,6 @@ import {
Moon,
Sun,
Monitor,
Search,
} from 'lucide-react';
export function Header() {
@ -85,18 +85,7 @@ export function Header() {
{/* Barre de recherche (desktop) */}
<div className="hidden md:flex flex-1 max-w-md mx-4">
<div className="relative w-full">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<input
type="text"
placeholder={t('common.search')}
aria-label={t('common.search')}
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
</div>
<GlobalSearchBar className="w-full" />
</div>
{/* Actions utilisateur */}

View file

@ -0,0 +1,138 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, type SearchResult } from './Search';
import { searchTracks } from '@/features/tracks/services/trackListService';
import { searchPlaylists } from '@/features/playlists/services/playlistService';
import { searchUsers } from '@/features/search/services/searchService';
import { useTranslation } from '@/hooks/useTranslation';
/**
* FE-COMP-008: Global search bar component with autocomplete
* Can be used in the header or anywhere in the app
*/
export interface GlobalSearchBarProps {
className?: string;
placeholder?: string;
onSearch?: (query: string) => void;
}
/**
* Global search bar with autocomplete suggestions for tracks, playlists, and users
*/
export function GlobalSearchBar({
className,
placeholder,
onSearch,
}: GlobalSearchBarProps) {
const navigate = useNavigate();
const { t } = useTranslation();
// Fetch autocomplete suggestions
const fetchSuggestions = useCallback(
async (query: string): Promise<SearchResult[]> => {
if (!query.trim()) {
return [];
}
try {
// Fetch suggestions from all sources in parallel
const [tracksData, playlistsData, usersData] = await Promise.allSettled([
searchTracks(query, { pagination: { page: 1, limit: 3 } }),
searchPlaylists({ q: query, page: 1, limit: 3 }),
searchUsers({ query, page: 1, limit: 3 }),
]);
const results: SearchResult[] = [];
// Add track suggestions
if (tracksData.status === 'fulfilled' && tracksData.value?.data) {
tracksData.value.data.forEach((track: any) => {
results.push({
id: track.id,
type: 'track',
title: track.title,
subtitle: track.artist ? `by ${track.artist}` : undefined,
image: track.cover_url,
});
});
}
// Add playlist suggestions
if (playlistsData.status === 'fulfilled' && playlistsData.value?.playlists) {
playlistsData.value.playlists.forEach((playlist: any) => {
results.push({
id: playlist.id,
type: 'playlist',
title: playlist.title,
subtitle: playlist.is_public ? 'Public' : 'Private',
image: playlist.cover_url,
});
});
}
// Add user suggestions
if (usersData.status === 'fulfilled' && usersData.value?.users) {
usersData.value.users.forEach((user: any) => {
results.push({
id: user.id,
type: 'user',
title: user.username,
subtitle: user.email,
image: user.avatar_url,
});
});
}
return results.slice(0, 8); // Limit to 8 total suggestions
} catch (error) {
console.error('Error fetching search suggestions:', error);
return [];
}
},
[],
);
// Handle search action
const handleSearch = useCallback(
(query: string) => {
if (query.trim()) {
navigate(`/search?q=${encodeURIComponent(query)}`);
onSearch?.(query);
}
},
[navigate, onSearch],
);
// Handle result selection
const handleResultSelect = useCallback(
(result: SearchResult) => {
switch (result.type) {
case 'track':
navigate(`/tracks/${result.id}`);
break;
case 'playlist':
navigate(`/playlists/${result.id}`);
break;
case 'user':
navigate(`/users/${result.id}`);
break;
}
},
[navigate],
);
return (
<Search
onSearch={handleSearch}
onResultSelect={handleResultSelect}
fetchSuggestions={fetchSuggestions}
placeholder={placeholder || t('common.search') || 'Search tracks, playlists, users...'}
showSuggestions={true}
showHistory={true}
className={className}
debounceDelay={300}
/>
);
}