359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface SearchSuggestion {
|
|
id: string;
|
|
text: string;
|
|
type: 'song' | 'album' | 'artist';
|
|
entity?: {
|
|
id: string;
|
|
title: string;
|
|
artist?: string;
|
|
album?: string;
|
|
};
|
|
}
|
|
|
|
interface SearchBarProps {
|
|
onSearch?: (query: string) => void;
|
|
onSuggestionSelect?: (suggestion: SearchSuggestion) => void;
|
|
placeholder?: string;
|
|
autoFocus?: boolean;
|
|
showSuggestions?: boolean;
|
|
debounceMs?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export default function SearchBar({
|
|
onSearch,
|
|
onSuggestionSelect,
|
|
placeholder = 'Search songs, artists, albums...',
|
|
autoFocus = false,
|
|
showSuggestions = true,
|
|
debounceMs = 300,
|
|
className = '',
|
|
}: SearchBarProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [showSuggestionsList, setShowSuggestionsList] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Fetch search suggestions
|
|
const fetchSuggestions = async (searchQuery: string) => {
|
|
if (!searchQuery.trim()) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
|
|
// Cancel previous request
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
|
|
abortControllerRef.current = new AbortController();
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${API_PATHS.SEARCH_SUGGESTIONS}?q=${encodeURIComponent(searchQuery)}&limit=8`,
|
|
{
|
|
signal: abortControllerRef.current.signal,
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch suggestions');
|
|
}
|
|
|
|
// Assuming the API returns an array of suggestions
|
|
// The actual structure would depend on the backend implementation
|
|
const data = await response.json();
|
|
const suggestionsData: SearchSuggestion[] = data.map((item: any) => ({
|
|
id: item.id || Math.random().toString(36),
|
|
text: item.title || item.name || item.text,
|
|
type: item.type || 'song',
|
|
entity: item,
|
|
}));
|
|
|
|
setSuggestions(suggestionsData);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to fetch suggestions:', error);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Handle input change with debouncing
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setQuery(value);
|
|
setActiveIndex(-1);
|
|
|
|
// Clear existing timeout
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
|
|
// Debounce the search
|
|
debounceTimeoutRef.current = setTimeout(() => {
|
|
if (showSuggestions) {
|
|
fetchSuggestions(value);
|
|
}
|
|
onSearch?.(value);
|
|
}, debounceMs);
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setShowSuggestionsList(false);
|
|
onSearch?.(query);
|
|
|
|
// Trigger immediate search
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
onSearch?.(query);
|
|
};
|
|
|
|
// Handle suggestion click
|
|
const handleSuggestionClick = (suggestion: SearchSuggestion) => {
|
|
setQuery(suggestion.text);
|
|
setShowSuggestionsList(false);
|
|
onSuggestionSelect?.(suggestion);
|
|
onSearch?.(suggestion.text);
|
|
};
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!showSuggestionsList || suggestions.length === 0) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setActiveIndex(prev => (prev + 1) % suggestions.length);
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setActiveIndex(prev => (prev - 1 + suggestions.length) % suggestions.length);
|
|
break;
|
|
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (activeIndex >= 0) {
|
|
handleSuggestionClick(suggestions[activeIndex]);
|
|
} else {
|
|
handleSubmit(e);
|
|
}
|
|
break;
|
|
|
|
case 'Escape':
|
|
setShowSuggestionsList(false);
|
|
setActiveIndex(-1);
|
|
inputRef.current?.blur();
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Handle input focus
|
|
const handleFocus = () => {
|
|
if (showSuggestions && query.trim()) {
|
|
setShowSuggestionsList(true);
|
|
}
|
|
};
|
|
|
|
// Handle input blur
|
|
const handleBlur = () => {
|
|
// Delay hiding suggestions to allow click events to fire
|
|
setTimeout(() => {
|
|
setShowSuggestionsList(false);
|
|
}, 150);
|
|
};
|
|
|
|
// Clear search
|
|
const handleClear = () => {
|
|
setQuery('');
|
|
setSuggestions([]);
|
|
setActiveIndex(-1);
|
|
setShowSuggestionsList(false);
|
|
onSearch?.('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
// Clean up on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Get suggestion icon
|
|
const getSuggestionIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'song':
|
|
return (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
|
</svg>
|
|
);
|
|
case 'album':
|
|
return (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
|
</svg>
|
|
);
|
|
case 'artist':
|
|
return (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
|
</svg>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
<form onSubmit={handleSubmit} className="relative">
|
|
<div className="relative">
|
|
{/* Search icon */}
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
|
{isLoading ? (
|
|
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input field */}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
placeholder={placeholder}
|
|
autoFocus={autoFocus}
|
|
className="w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
|
/>
|
|
|
|
{/* Clear button */}
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Suggestions dropdown */}
|
|
{showSuggestionsList && suggestions.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-96 overflow-y-auto">
|
|
{suggestions.map((suggestion, index) => (
|
|
<button
|
|
key={suggestion.id}
|
|
onClick={() => handleSuggestionClick(suggestion)}
|
|
onMouseEnter={() => setActiveIndex(index)}
|
|
className={`w-full px-4 py-3 text-left flex items-center gap-3 transition-colors ${
|
|
index === activeIndex
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="text-gray-400 flex-shrink-0">
|
|
{getSuggestionIcon(suggestion.type)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{suggestion.text}</div>
|
|
{suggestion.entity?.artist && (
|
|
<div className="text-sm text-gray-500 truncate">
|
|
{suggestion.entity.artist}
|
|
{suggestion.entity?.album && ` • ${suggestion.entity.album}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-400 capitalize flex-shrink-0">
|
|
{suggestion.type}
|
|
</div>
|
|
</button>
|
|
))}
|
|
|
|
{/* Search for full query option */}
|
|
{query.trim() && (
|
|
<button
|
|
onClick={() => {
|
|
onSearch?.(query);
|
|
setShowSuggestionsList(false);
|
|
}}
|
|
className="w-full px-4 py-2 text-left flex items-center gap-3 bg-gray-50 hover:bg-gray-100 transition-colors border-t border-gray-200"
|
|
>
|
|
<div className="text-gray-400">
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
Search for "<span className="font-medium">{query}</span>"
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |