spotify-clone/frontend-vite/src/pages/Search.tsx

270 lines
13 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { Search as SearchIcon, Play, Heart, PlusCircle, Loader2, Music2, Disc, User } from 'lucide-react';
import { useSearchParams, Link } from 'react-router-dom';
import { usePlayer } from '../context/PlayerContext';
import { libraryService } from '../services/library';
import { Track } from '../types';
import CoverImage from '../components/CoverImage';
import AddToPlaylistModal from '../components/AddToPlaylistModal';
import Skeleton from '../components/Skeleton';
import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
// Helper to extract unique items
function extractUniqueItems(tracks: Track[], key: 'artist' | 'album') {
const seen = new Set();
const items: { name: string, image?: string, id: string }[] = [];
tracks.forEach(t => {
const val = t[key];
if (val && !seen.has(val)) {
seen.add(val);
items.push({
name: val,
image: t.cover_url, // Use track cover as proxy for now
id: `${key}-${val}` // robust id
});
}
});
return items.slice(0, 5); // Limit to top 5
}
export default function Search() {
const [searchParams, setSearchParams] = useSearchParams();
const routerQuery = searchParams.get('q') || '';
// Initialize state from local storage or router
const [query, setQuery] = useState(() => {
return routerQuery || localStorage.getItem('last_search_query') || '';
});
const [results, setResults] = useState<Track[]>(() => {
const cached = localStorage.getItem('last_search_results');
return cached ? JSON.parse(cached) : [];
});
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [selectedTrack, setSelectedTrack] = useState<Track | null>(null);
const { playTrack, likedTracks, toggleLike } = usePlayer();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Derived State for Categories
const relatedArtists = extractUniqueItems(results, 'artist');
const relatedAlbums = extractUniqueItems(results, 'album');
// Persistence Effect
useEffect(() => {
if (query) localStorage.setItem('last_search_query', query);
if (results.length > 0) localStorage.setItem('last_search_results', JSON.stringify(results));
}, [query, results]);
// Perform Search
const performSearch = useCallback(async (searchQuery: string, isLoadMore = false) => {
if (!searchQuery.trim()) {
if (!isLoadMore) setResults([]);
return;
}
if (!isLoadMore) setLoading(true);
try {
// "Smart Crawling" - fetching data
const tracks = await libraryService.search(searchQuery);
if (isLoadMore) {
setResults(prev => {
const existingIds = new Set(prev.map(t => t.id));
const newTracks = tracks.filter(t => !existingIds.has(t.id));
if (newTracks.length === 0) setHasMore(false);
return [...prev, ...newTracks];
});
} else {
setResults(tracks);
if (tracks.length < 5) setHasMore(false);
}
} catch (error) {
console.error("Search error:", error);
} finally {
setLoading(false);
}
}, []);
const loadMore = () => {
if (!loading && hasMore && query) {
performSearch(query, true);
}
};
const lastElementRef = useInfiniteScroll(loadMore, loading);
// Sync URL with State
useEffect(() => {
if (routerQuery && routerQuery !== query) {
setQuery(routerQuery);
performSearch(routerQuery);
} else if (!routerQuery && query && results.length === 0) {
// If nothing in URL but we have a stored query + no results, fetch
// But if we have results from storage, maybe don't fetch immediately?
// Let's refetch to be fresh if nothing in URL strictly (or just rely on storage)
// For now, if we have results, we show them.
if (!results.length) performSearch(query);
// Update URL to match restored query
setSearchParams({ q: query }, { replace: true });
}
}, [routerQuery]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (value.trim()) setSearchParams({ q: value });
performSearch(value);
}, 500);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (debounceRef.current) clearTimeout(debounceRef.current);
setSearchParams({ q: query });
performSearch(query);
};
return (
<div className="h-full overflow-y-auto p-4 md:p-6 no-scrollbar pb-24">
{/* Search Bar */}
<form onSubmit={handleSubmit} className="mb-6 sticky top-0 z-20 bg-gradient-to-b from-[#1e1e1e] to-transparent pb-4">
<div className="relative max-w-xl">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-400" />
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="What do you want to listen to?"
className="w-full pl-12 pr-4 py-3 bg-white text-black rounded-full font-medium placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[#1DB954]"
/>
</div>
</form>
{loading && results.length === 0 ? (
<div className="space-y-8 animate-pulse">
<Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 fold:grid-cols-4 lg:grid-cols-5 gap-4">
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="aspect-square rounded-xl" />)}
</div>
</div>
) : results.length > 0 ? (
<div className="space-y-8 fade-in">
{/* Top Result - Related Artists */}
{relatedArtists.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Artists</h2>
<div className="flex gap-3 overflow-x-auto pb-4 no-scrollbar">
{relatedArtists.map(artist => (
<Link to={`/artist/${encodeURIComponent(artist.name)}`} key={artist.id} className="flex-shrink-0 w-32 md:w-40 text-center group">
<div className="relative mb-2">
<CoverImage src={artist.image} alt={artist.name} className="w-32 h-32 md:w-40 md:h-40 rounded-full shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={artist.name[0]} />
</div>
<p className="font-bold truncate text-[11px] md:text-base">{artist.name}</p>
<p className="text-[10px] md:text-sm text-[#a7a7a7]">Artist</p>
</Link>
))}
</div>
</div>
)}
{/* Related Albums */}
{relatedAlbums.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4">Albums</h2>
<div className="flex gap-3 overflow-x-auto pb-4 no-scrollbar">
{relatedAlbums.map(album => (
<Link to={`/album/search-${encodeURIComponent(album.name)}`} key={album.id} className="flex-shrink-0 w-32 md:w-40 group">
<div className="relative mb-2">
<CoverImage src={album.image} alt={album.name} className="w-32 h-32 md:w-40 md:h-40 rounded-2xl shadow-lg object-cover group-hover:shadow-xl transition" fallbackText={album.name[0]} />
</div>
<p className="font-bold truncate text-[11px] md:text-base">{album.name}</p>
<p className="text-[10px] md:text-sm text-[#a7a7a7]">Album</p>
</Link>
))}
</div>
</div>
)}
{/* Songs List */}
<div>
<h2 className="text-2xl font-bold mb-4">Songs</h2>
<div className="space-y-2">
{results.map((track, index) => (
<div
key={`${track.id}-${index}`}
className="flex items-center gap-4 p-2 rounded-md hover:bg-spotify-card-hover transition group cursor-pointer"
onClick={() => playTrack(track, results)}
>
<div className="w-8 text-center text-neutral-400 group-hover:hidden">{index + 1}</div>
<div className="w-8 hidden group-hover:flex items-center justify-center text-white">
<Play className="w-4 h-4 fill-current" />
</div>
<CoverImage src={track.cover_url} alt={track.title} className="w-10 h-10 rounded-lg" fallbackText="♪" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
</div>
<p className="hidden md:block text-sm text-neutral-400 truncate max-w-[200px]">{track.album}</p>
<div className="flex items-center gap-3 ml-4">
<button onClick={(e) => { e.stopPropagation(); toggleLike(track); }} className={`transition opacity-0 group-hover:opacity-100 ${likedTracks.has(track.id) ? 'text-[#1DB954] opacity-100' : 'text-neutral-400 hover:text-white'}`}>
<Heart className={`w-5 h-5 ${likedTracks.has(track.id) ? 'fill-current' : ''}`} />
</button>
<button onClick={(e) => { e.stopPropagation(); setSelectedTrack(track); }} className="text-neutral-400 hover:text-white transition opacity-0 group-hover:opacity-100">
<PlusCircle className="w-5 h-5" />
</button>
</div>
<span className="text-sm text-neutral-400 w-12 text-right">
{track.duration ? `${Math.floor(track.duration / 60)}:${(track.duration % 60).toString().padStart(2, '0')}` : '--:--'}
</span>
</div>
))}
</div>
</div>
{/* Infinite Scroll & Skeleton */}
<div ref={lastElementRef} className="py-8">
{loading && (
<div className="space-y-4 animate-pulse">
<Skeleton className="h-8 w-48 mb-4" />
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-4 p-2">
<Skeleton className="w-8 h-8 rounded" />
<Skeleton className="w-10 h-10 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
) : (
<div className="text-center py-20">
<SearchIcon className="w-16 h-16 mx-auto text-neutral-600 mb-4" />
<h2 className="text-xl font-bold mb-2">Search for music</h2>
<p className="text-neutral-400">Find your favorite songs, artists, and albums.</p>
</div>
)}
{selectedTrack && (
<AddToPlaylistModal
track={selectedTrack}
isOpen={true}
onClose={() => setSelectedTrack(null)}
/>
)}
</div>
);
}