project-standalo-sonic-cloud/app/components/SearchBar.tsx

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>
);
}