530 lines
19 KiB
TypeScript
530 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import type { SearchIndex } from '../types/api';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface SearchResultItem {
|
|
id: string;
|
|
type: 'song' | 'album' | 'artist';
|
|
title: string;
|
|
subtitle?: string;
|
|
image?: string;
|
|
duration?: number;
|
|
year?: number;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
interface SearchResultsProps {
|
|
query: string;
|
|
onResultClick?: (item: SearchResultItem) => void;
|
|
onPlaySong?: (songId: string) => void;
|
|
onAddToQueue?: (songId: string) => void;
|
|
onAddToPlaylist?: (songId: string) => void;
|
|
maxResults?: number;
|
|
showFilters?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export default function SearchResults({
|
|
query,
|
|
onResultClick,
|
|
onPlaySong,
|
|
onAddToQueue,
|
|
onAddToPlaylist,
|
|
maxResults = 50,
|
|
showFilters = true,
|
|
className = '',
|
|
}: SearchResultsProps) {
|
|
const [results, setResults] = useState<{
|
|
songs: SearchResultItem[];
|
|
albums: SearchResultItem[];
|
|
artists: SearchResultItem[];
|
|
}>({
|
|
songs: [],
|
|
albums: [],
|
|
artists: [],
|
|
});
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'all' | 'songs' | 'albums' | 'artists'>('all');
|
|
const [sortBy, setSortBy] = useState<'relevance' | 'newest' | 'oldest' | 'name'>('relevance');
|
|
|
|
// Fetch search results
|
|
useEffect(() => {
|
|
if (!query.trim()) {
|
|
setResults({ songs: [], albums: [], artists: [] });
|
|
return;
|
|
}
|
|
|
|
const fetchResults = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${API_PATHS.SEARCH}?q=${encodeURIComponent(query)}&limit=${maxResults}&sort=${sortBy}`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch search results');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Transform API response to our format
|
|
// This would depend on the actual API response structure
|
|
const transformedResults = {
|
|
songs: (data.songs || []).map((item: any) => ({
|
|
id: item.id,
|
|
type: 'song' as const,
|
|
title: item.title,
|
|
subtitle: item.artist,
|
|
image: item.coverArt,
|
|
duration: item.duration,
|
|
year: item.year,
|
|
metadata: item.metadata,
|
|
})),
|
|
albums: (data.albums || []).map((item: any) => ({
|
|
id: item.id,
|
|
type: 'album' as const,
|
|
title: item.title,
|
|
subtitle: item.artist,
|
|
image: item.coverArt,
|
|
year: item.year,
|
|
metadata: item.metadata,
|
|
})),
|
|
artists: (data.artists || []).map((item: any) => ({
|
|
id: item.id,
|
|
type: 'artist' as const,
|
|
title: item.name,
|
|
subtitle: `${item.albumCount || 0} albums`,
|
|
image: item.image,
|
|
metadata: item.metadata,
|
|
})),
|
|
};
|
|
|
|
setResults(transformedResults);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchResults();
|
|
}, [query, maxResults, sortBy]);
|
|
|
|
// Get filtered results based on active tab
|
|
const getFilteredResults = () => {
|
|
switch (activeTab) {
|
|
case 'songs':
|
|
return results.songs;
|
|
case 'albums':
|
|
return results.albums;
|
|
case 'artists':
|
|
return results.artists;
|
|
default:
|
|
return [...results.songs, ...results.albums, ...results.artists];
|
|
}
|
|
};
|
|
|
|
// Format duration helper
|
|
const formatDuration = (seconds?: number): string => {
|
|
if (!seconds) return '';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Handle result click
|
|
const handleResultClick = (item: SearchResultItem) => {
|
|
onResultClick?.(item);
|
|
};
|
|
|
|
// Handle play song
|
|
const handlePlaySong = (e: React.MouseEvent, songId: string) => {
|
|
e.stopPropagation();
|
|
onPlaySong?.(songId);
|
|
};
|
|
|
|
// Handle add to queue
|
|
const handleAddToQueue = (e: React.MouseEvent, songId: string) => {
|
|
e.stopPropagation();
|
|
onAddToQueue?.(songId);
|
|
};
|
|
|
|
// Handle add to playlist
|
|
const handleAddToPlaylist = (e: React.MouseEvent, songId: string) => {
|
|
e.stopPropagation();
|
|
onAddToPlaylist?.(songId);
|
|
};
|
|
|
|
// Render song item
|
|
const renderSongItem = (item: SearchResultItem) => (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleResultClick(item)}
|
|
className="group flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
|
>
|
|
{/* Album art */}
|
|
<div className="relative w-12 h-12 bg-gray-200 rounded-md overflow-hidden flex-shrink-0">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
|
<svg className="w-6 h-6" 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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Play button overlay */}
|
|
<button
|
|
onClick={(e) => handlePlaySong(e, item.id)}
|
|
className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100"
|
|
>
|
|
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Song info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
|
</div>
|
|
|
|
{/* Duration */}
|
|
<div className="text-sm text-gray-400 flex-shrink-0">
|
|
{formatDuration(item.duration)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={(e) => handleAddToQueue(e, item.id)}
|
|
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
|
title="Add to queue"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
onClick={(e) => handleAddToPlaylist(e, item.id)}
|
|
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
|
title="Add to playlist"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M4 10h12v2H4zm0-4h12v2H4zm0 8h8v2H4zm10 0v6l5-3z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Render album item
|
|
const renderAlbumItem = (item: SearchResultItem) => (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleResultClick(item)}
|
|
className="group cursor-pointer"
|
|
>
|
|
<div className="relative aspect-square bg-gray-200 rounded-lg overflow-hidden mb-2">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
|
<svg className="w-12 h-12" 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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Play button overlay */}
|
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 flex items-center justify-center transition-all">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
// Play first song from album
|
|
onPlaySong?.(item.id);
|
|
}}
|
|
className="w-12 h-12 bg-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transform scale-90 group-hover:scale-100 transition-all shadow-lg"
|
|
>
|
|
<svg className="w-6 h-6 text-blue-600 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
|
{item.year && (
|
|
<p className="text-xs text-gray-400">{item.year}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Render artist item
|
|
const renderArtistItem = (item: SearchResultItem) => (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleResultClick(item)}
|
|
className="group cursor-pointer text-center"
|
|
>
|
|
<div className="relative w-24 h-24 mx-auto mb-2">
|
|
<div className="w-full h-full bg-gray-200 rounded-full overflow-full">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.title}
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
|
<svg className="w-12 h-12" 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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
|
</div>
|
|
);
|
|
|
|
const filteredResults = getFilteredResults();
|
|
const allResultsCount = results.songs.length + results.albums.length + results.artists.length;
|
|
|
|
if (!query.trim()) {
|
|
return (
|
|
<div className={`text-center py-12 ${className}`}>
|
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" 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>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Search for music</h3>
|
|
<p className="text-gray-500">Find your favorite songs, artists, and albums</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={className}>
|
|
{/* Filters and controls */}
|
|
{showFilters && (
|
|
<div className="mb-6">
|
|
{/* Tabs */}
|
|
<div className="flex gap-4 mb-4 border-b border-gray-200">
|
|
<button
|
|
onClick={() => setActiveTab('all')}
|
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
|
activeTab === 'all'
|
|
? 'text-blue-600 border-b-2 border-blue-600'
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
All ({allResultsCount})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('songs')}
|
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
|
activeTab === 'songs'
|
|
? 'text-blue-600 border-b-2 border-blue-600'
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
Songs ({results.songs.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('albums')}
|
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
|
activeTab === 'albums'
|
|
? 'text-blue-600 border-b-2 border-blue-600'
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
Albums ({results.albums.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('artists')}
|
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
|
activeTab === 'artists'
|
|
? 'text-blue-600 border-b-2 border-blue-600'
|
|
: 'text-gray-500 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
Artists ({results.artists.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<div className="flex justify-between items-center">
|
|
<p className="text-sm text-gray-600">
|
|
{isLoading
|
|
? 'Searching...'
|
|
: `Found ${filteredResults.length} result${filteredResults.length !== 1 ? 's' : ''}`}
|
|
</p>
|
|
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as any)}
|
|
className="px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="relevance">Most Relevant</option>
|
|
<option value="newest">Newest</option>
|
|
<option value="oldest">Oldest</option>
|
|
<option value="name">Name (A-Z)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && (
|
|
<div className="flex justify-center py-12">
|
|
<svg className="animate-spin h-8 w-8 text-blue-600" 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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<div className="text-center py-12">
|
|
<svg className="mx-auto h-12 w-12 text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Error loading results</h3>
|
|
<p className="text-gray-500">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{!isLoading && !error && filteredResults.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M12 12h-.01M12 12h-.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
|
|
<p className="text-gray-500">Try adjusting your search or filters</p>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !error && filteredResults.length > 0 && (
|
|
<div className={activeTab === 'all' ? 'space-y-6' : ''}>
|
|
{activeTab === 'all' && (
|
|
<>
|
|
{/* Songs section */}
|
|
{results.songs.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 mb-3">Songs</h3>
|
|
<div className="space-y-1">
|
|
{results.songs.slice(0, 5).map(renderSongItem)}
|
|
{results.songs.length > 5 && (
|
|
<button
|
|
onClick={() => setActiveTab('songs')}
|
|
className="w-full py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
Show all {results.songs.length} songs →
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Albums section */}
|
|
{results.albums.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 mb-3">Albums</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{results.albums.slice(0, 5).map(renderAlbumItem)}
|
|
{results.albums.length > 5 && (
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
onClick={() => setActiveTab('albums')}
|
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
+{results.albums.length - 5} more
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Artists section */}
|
|
{results.artists.length > 0 && (
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 mb-3">Artists</h3>
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
|
|
{results.artists.slice(0, 6).map(renderArtistItem)}
|
|
{results.artists.length > 6 && (
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
onClick={() => setActiveTab('artists')}
|
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
+{results.artists.length - 6} more
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'songs' && (
|
|
<div className="space-y-1">
|
|
{results.songs.map(renderSongItem)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'albums' && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{results.albums.map(renderAlbumItem)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'artists' && (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
|
|
{results.artists.map(renderArtistItem)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |