[FE-COMP-008] fe-comp: Add search bar component
This commit is contained in:
parent
944a1b2197
commit
b4b68ff49d
3 changed files with 148 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
138
apps/web/src/components/search/GlobalSearchBar.tsx
Normal file
138
apps/web/src/components/search/GlobalSearchBar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue