174 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|