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

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