- Ajouter margin-left au contenu principal pour compenser la sidebar fixe - Margin dynamique basé sur l'état ouvert/fermé de la sidebar - Augmenter z-index du dropdown de recherche à z-[110] pour être au-dessus du header (z-100) - Ajouter z-index au wrapper de recherche dans Header - Le contenu principal ne se superpose plus avec la sidebar - Le dropdown de recherche s'affiche correctement au-dessus de tous les éléments
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { useDebounce } from '@/hooks/useDebounce';
|
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Search as SearchIcon,
|
|
X,
|
|
Clock,
|
|
Music,
|
|
User,
|
|
List,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export interface SearchResult {
|
|
id: string;
|
|
type: 'track' | 'user' | 'playlist';
|
|
title: string;
|
|
subtitle?: string;
|
|
image?: string;
|
|
}
|
|
|
|
export interface SearchProps {
|
|
onSearch: (query: string) => void;
|
|
onResultSelect?: (result: SearchResult) => void;
|
|
placeholder?: string;
|
|
showSuggestions?: boolean;
|
|
showHistory?: boolean;
|
|
maxHistoryItems?: number;
|
|
fetchSuggestions?: (
|
|
query: string,
|
|
) => Promise<SearchResult[]> | SearchResult[];
|
|
className?: string;
|
|
debounceDelay?: number;
|
|
}
|
|
|
|
const HISTORY_KEY = 'veza_search_history';
|
|
const MAX_HISTORY_ITEMS = 10;
|
|
|
|
/**
|
|
* Composant Search avec autocomplete, suggestions, et historique.
|
|
*/
|
|
export function Search({
|
|
onSearch,
|
|
onResultSelect,
|
|
placeholder = 'Rechercher...',
|
|
showSuggestions = true,
|
|
showHistory = true,
|
|
maxHistoryItems = MAX_HISTORY_ITEMS,
|
|
fetchSuggestions,
|
|
className,
|
|
debounceDelay = 300,
|
|
}: SearchProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [history, setHistory, removeHistory] = useLocalStorage<string[]>(
|
|
HISTORY_KEY,
|
|
[],
|
|
);
|
|
const searchRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Debounce de la query pour la recherche
|
|
const debouncedQuery = useDebounce(query, debounceDelay);
|
|
|
|
// Log query changes
|
|
useEffect(() => {
|
|
console.log('[🔍 Search] Query changed', {
|
|
rawQuery: query,
|
|
debouncedQuery,
|
|
debounceDelay,
|
|
});
|
|
}, [query, debouncedQuery, debounceDelay]);
|
|
|
|
// Fetch suggestions lorsque la query change
|
|
useEffect(() => {
|
|
console.log('[🔍 Search] useEffect triggered for suggestions', {
|
|
debouncedQuery,
|
|
hasFetchSuggestions: !!fetchSuggestions,
|
|
showSuggestions,
|
|
});
|
|
|
|
if (!debouncedQuery.trim() || !fetchSuggestions || !showSuggestions) {
|
|
console.log('[🔍 Search] Skipping suggestions fetch', {
|
|
reason: !debouncedQuery.trim()
|
|
? 'empty query'
|
|
: !fetchSuggestions
|
|
? 'no fetchSuggestions'
|
|
: 'showSuggestions disabled',
|
|
});
|
|
setSuggestions([]);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
console.log('[🔍 Search] Starting suggestions fetch', { debouncedQuery });
|
|
setIsLoading(true);
|
|
const loadSuggestions = async () => {
|
|
const startTime = performance.now();
|
|
try {
|
|
const results = await Promise.resolve(fetchSuggestions(debouncedQuery));
|
|
const duration = performance.now() - startTime;
|
|
console.log('[🔍 Search] Suggestions fetched', {
|
|
count: results.length,
|
|
duration: `${duration.toFixed(2)}ms`,
|
|
results: results.map((r) => ({ type: r.type, title: r.title })),
|
|
});
|
|
setSuggestions(results);
|
|
logger.debug('Suggestions loaded', {
|
|
query: debouncedQuery,
|
|
count: results.length,
|
|
component: 'Search',
|
|
});
|
|
} catch (error) {
|
|
const duration = performance.now() - startTime;
|
|
console.error('[🔍 Search] Error fetching suggestions', {
|
|
error,
|
|
query: debouncedQuery,
|
|
duration: `${duration.toFixed(2)}ms`,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
});
|
|
logger.error('Error fetching suggestions', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
query: debouncedQuery,
|
|
component: 'Search',
|
|
});
|
|
setSuggestions([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
console.log('[🔍 Search] Suggestions fetch completed', { isLoading: false });
|
|
}
|
|
};
|
|
|
|
loadSuggestions();
|
|
}, [debouncedQuery, fetchSuggestions, showSuggestions]);
|
|
|
|
// FIX: Ne pas appeler onSearch automatiquement à chaque changement de query
|
|
// onSearch sera appelé seulement quand l'utilisateur soumet la recherche (Enter ou clic)
|
|
// Cela évite de naviguer vers /search à chaque frappe
|
|
// L'effet précédent qui appelait onSearch à chaque debouncedQuery a été supprimé
|
|
|
|
// Gérer le clic en dehors pour fermer le dropdown
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
searchRef.current &&
|
|
!searchRef.current.contains(event.target as Node)
|
|
) {
|
|
setShowDropdown(false);
|
|
setActiveIndex(-1);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
console.log('[🔍 Search] Input changed', { value, length: value.length });
|
|
setQuery(value);
|
|
setShowDropdown(true);
|
|
setActiveIndex(-1);
|
|
};
|
|
|
|
const handleInputFocus = () => {
|
|
console.log('[🔍 Search] Input focused', {
|
|
query,
|
|
hasHistory: showHistory && history.length > 0,
|
|
historyCount: history.length,
|
|
});
|
|
if (query.trim() || (showHistory && history.length > 0)) {
|
|
setShowDropdown(true);
|
|
}
|
|
};
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
const items = [
|
|
...(showHistory && query.trim() === '' ? history.slice(0, 5) : []),
|
|
...suggestions,
|
|
];
|
|
|
|
console.log('[🔍 Search] Key pressed', {
|
|
key: e.key,
|
|
activeIndex,
|
|
itemsCount: items.length,
|
|
query,
|
|
});
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
const newIndexDown = activeIndex < items.length - 1 ? activeIndex + 1 : activeIndex;
|
|
console.log('[🔍 Search] ArrowDown', { previousIndex: activeIndex, newIndex: newIndexDown });
|
|
setActiveIndex(newIndexDown);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
const newIndexUp = activeIndex > 0 ? activeIndex - 1 : -1;
|
|
console.log('[🔍 Search] ArrowUp', { previousIndex: activeIndex, newIndex: newIndexUp });
|
|
setActiveIndex(newIndexUp);
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
console.log('[🔍 Search] Enter pressed', {
|
|
activeIndex,
|
|
itemsCount: items.length,
|
|
query,
|
|
});
|
|
if (activeIndex >= 0 && activeIndex < items.length) {
|
|
if (
|
|
activeIndex <
|
|
(showHistory && query.trim() === '' ? history.length : 0)
|
|
) {
|
|
// C'est un item de l'historique
|
|
const historyItem = history[activeIndex];
|
|
console.log('[🔍 Search] Selecting history item', { historyItem, index: activeIndex });
|
|
setQuery(historyItem);
|
|
handleSearch(historyItem);
|
|
} else {
|
|
// C'est une suggestion
|
|
const suggestionIndex =
|
|
activeIndex -
|
|
(showHistory && query.trim() === ''
|
|
? Math.min(history.length, 5)
|
|
: 0);
|
|
const result = suggestions[suggestionIndex];
|
|
console.log('[🔍 Search] Selecting suggestion', {
|
|
suggestionIndex,
|
|
result: result ? { type: result.type, title: result.title } : null,
|
|
});
|
|
handleResultSelect(result);
|
|
}
|
|
} else if (query.trim()) {
|
|
console.log('[🔍 Search] Submitting query directly', { query });
|
|
handleSearch(query);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
console.log('[🔍 Search] Escape pressed, closing dropdown');
|
|
setShowDropdown(false);
|
|
setActiveIndex(-1);
|
|
inputRef.current?.blur();
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleSearch = useCallback(
|
|
(searchQuery: string) => {
|
|
console.log('[🔍 Search] handleSearch called', { searchQuery });
|
|
if (searchQuery.trim()) {
|
|
// Ajouter à l'historique
|
|
if (showHistory) {
|
|
setHistory((prev) => {
|
|
const filtered = prev.filter((item) => item !== searchQuery);
|
|
const updated = [searchQuery, ...filtered].slice(
|
|
0,
|
|
maxHistoryItems,
|
|
);
|
|
console.log('[🔍 Search] History updated', {
|
|
previousCount: prev.length,
|
|
newCount: updated.length,
|
|
added: searchQuery,
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
setShowDropdown(false);
|
|
console.log('[🔍 Search] Calling onSearch callback', { searchQuery });
|
|
onSearch(searchQuery);
|
|
logger.debug('Search submitted from Search component', {
|
|
query: searchQuery,
|
|
component: 'Search',
|
|
});
|
|
} else {
|
|
console.log('[🔍 Search] Empty search query, skipping');
|
|
}
|
|
},
|
|
[onSearch, showHistory, setHistory, maxHistoryItems],
|
|
);
|
|
|
|
const handleResultSelect = useCallback(
|
|
(result: SearchResult) => {
|
|
console.log('[🔍 Search] handleResultSelect called', {
|
|
type: result.type,
|
|
id: result.id,
|
|
title: result.title,
|
|
});
|
|
setQuery(result.title);
|
|
setShowDropdown(false);
|
|
setActiveIndex(-1);
|
|
onResultSelect?.(result);
|
|
logger.debug('Search result selected from Search component', {
|
|
type: result.type,
|
|
id: result.id,
|
|
component: 'Search',
|
|
});
|
|
},
|
|
[onResultSelect],
|
|
);
|
|
|
|
const handleHistoryItemClick = (item: string) => {
|
|
setQuery(item);
|
|
handleSearch(item);
|
|
};
|
|
|
|
const handleClearHistory = () => {
|
|
removeHistory();
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setQuery('');
|
|
setSuggestions([]);
|
|
setShowDropdown(false);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const getResultIcon = (type: SearchResult['type']) => {
|
|
switch (type) {
|
|
case 'track':
|
|
return <Music className="h-4 w-4" />;
|
|
case 'user':
|
|
return <User className="h-4 w-4" />;
|
|
case 'playlist':
|
|
return <List className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
const displayItems = useMemo(() => {
|
|
const items: Array<{
|
|
type: 'history' | 'suggestion';
|
|
data: string | SearchResult;
|
|
}> = [];
|
|
|
|
if (showHistory && query.trim() === '' && history.length > 0) {
|
|
items.push(
|
|
...history
|
|
.slice(0, 5)
|
|
.map((item) => ({ type: 'history' as const, data: item })),
|
|
);
|
|
}
|
|
|
|
if (showSuggestions && suggestions.length > 0) {
|
|
items.push(
|
|
...suggestions.map((result) => ({
|
|
type: 'suggestion' as const,
|
|
data: result,
|
|
})),
|
|
);
|
|
}
|
|
|
|
return items;
|
|
}, [showHistory, showSuggestions, query, history, suggestions]);
|
|
|
|
const shouldShowDropdown =
|
|
showDropdown &&
|
|
(displayItems.length > 0 || isLoading) &&
|
|
(showSuggestions || (showHistory && query.trim() === ''));
|
|
|
|
return (
|
|
<div ref={searchRef} className={cn('relative w-full', className)}>
|
|
<div className="relative">
|
|
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none z-10" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onFocus={handleInputFocus}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder={placeholder}
|
|
className={cn(
|
|
'w-full rounded-md border border-input bg-background pl-10 pr-10 py-2 text-sm',
|
|
'placeholder:text-muted-foreground',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
// Support pour className personnalisé (utilisé dans Header avec bg-transparent)
|
|
className && className.includes('bg-transparent') ? 'bg-transparent border-none' : '',
|
|
)}
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
aria-label="Clear search"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{shouldShowDropdown && (
|
|
<div className="absolute z-[110] mt-2 w-full rounded-md border bg-popover shadow-lg">
|
|
{isLoading && (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
Recherche en cours...
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && displayItems.length === 0 && query.trim() && (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
Aucun résultat trouvé
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && displayItems.length > 0 && (
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{showHistory && query.trim() === '' && history.length > 0 && (
|
|
<div className="border-b p-2">
|
|
<div className="flex items-center justify-between px-2 py-1">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
RÉCENTS
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClearHistory}
|
|
className="h-6 text-xs"
|
|
>
|
|
Effacer
|
|
</Button>
|
|
</div>
|
|
{history.slice(0, 5).map((item, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
onClick={() => handleHistoryItemClick(item)}
|
|
className={cn(
|
|
'w-full px-4 py-2 text-left text-sm hover:bg-accent',
|
|
'flex items-center gap-2',
|
|
activeIndex === index && 'bg-accent',
|
|
)}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
<span className="flex-1">{item}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showSuggestions && suggestions.length > 0 && (
|
|
<div className="p-2">
|
|
{query.trim() && (
|
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
SUGGESTIONS
|
|
</div>
|
|
)}
|
|
{suggestions.map((result, index) => {
|
|
const itemIndex =
|
|
(showHistory && query.trim() === ''
|
|
? Math.min(history.length, 5)
|
|
: 0) + index;
|
|
return (
|
|
<button
|
|
key={result.id}
|
|
type="button"
|
|
onClick={() => handleResultSelect(result)}
|
|
className={cn(
|
|
'w-full px-4 py-2 text-left text-sm hover:bg-accent',
|
|
'flex items-center gap-4',
|
|
activeIndex === itemIndex && 'bg-accent',
|
|
)}
|
|
>
|
|
<div className="flex h-8 w-8 items-center justify-center rounded bg-muted">
|
|
{getResultIcon(result.type)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{result.title}</div>
|
|
{result.subtitle && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{result.subtitle}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|