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

174 lines
6.3 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
export interface SearchSuggestion {
type: 'song' | 'artist' | 'album' | 'playlist'
id: string
title: string
subtitle?: string
}
export interface SearchBarProps {
placeholder?: string
onSearch: (query: string) => void
onSuggestionClick?: (suggestion: SearchSuggestion) => void
suggestions?: SearchSuggestion[]
isLoading?: boolean
autoFocus?: boolean
}
export function SearchBar({
placeholder = 'Search for songs, artists, albums...',
onSearch,
onSuggestionClick,
suggestions = [],
isLoading = false,
autoFocus = false
}: SearchBarProps) {
const [query, setQuery] = useState('')
const [isFocused, setIsFocused] = useState(false)
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
onSearch(query)
setShowSuggestions(false)
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
setShowSuggestions(value.length > 0)
}
const handleSuggestionClick = (suggestion: SearchSuggestion) => {
setQuery(suggestion.title)
setShowSuggestions(false)
onSuggestionClick?.(suggestion)
}
const getTypeIcon = (type: SearchSuggestion['type']) => {
const className = 'w-4 h-4 text-zinc-400'
switch (type) {
case 'song':
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
)
case 'artist':
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
)
case 'album':
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z" />
<path fillRule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
)
case 'playlist':
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
)
}
}
return (
<div ref={containerRef} className="relative w-full max-w-2xl">
<form onSubmit={handleSubmit} className="relative">
{/* Search Icon */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="w-5 h-5 text-zinc-400" 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 */}
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onFocus={() => {
setIsFocused(true)
if (query.length > 0) setShowSuggestions(true)
}}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
autoFocus={autoFocus}
className={`w-full pl-12 pr-12 py-3 bg-zinc-800 border-2 rounded-full text-white placeholder-zinc-500 focus:outline-none transition ${
isFocused ? 'border-purple-500' : 'border-zinc-700'
}`}
/>
{/* Clear Button */}
{query && (
<button
type="button"
onClick={() => {
setQuery('')
setShowSuggestions(false)
inputRef.current?.focus()
}}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-zinc-400 hover:text-white transition"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</button>
)}
{/* Loading Spinner */}
{isLoading && (
<div className="absolute right-4 top-1/2 -translate-y-1/2">
<div className="w-5 h-5 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</form>
{/* Suggestions Dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden z-50">
{suggestions.map((suggestion) => (
<button
key={`${suggestion.type}-${suggestion.id}`}
onClick={() => handleSuggestionClick(suggestion)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-zinc-800 transition text-left"
>
{getTypeIcon(suggestion.type)}
<div className="flex-1 min-w-0">
<p className="text-white truncate">{suggestion.title}</p>
{suggestion.subtitle && (
<p className="text-xs text-zinc-400 truncate">{suggestion.subtitle}</p>
)}
</div>
<span className="text-xs text-zinc-500 capitalize">{suggestion.type}</span>
</button>
))}
</div>
)}
</div>
)
}