spotify-clone/frontend/app/artist/page.tsx

271 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Play, Pause, Clock, User, Music, Plus, ChevronDown, ChevronUp } from "lucide-react";
import { usePlayer } from "@/context/PlayerContext";
import CoverImage from "@/components/CoverImage";
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import Link from "next/link";
import { Track } from "@/types";
function ArtistPageContent() {
const searchParams = useSearchParams();
const artistName = searchParams.get("name") || "";
const [allSongs, setAllSongs] = useState<Track[]>([]);
const [artistPhoto, setArtistPhoto] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAllSongs, setShowAllSongs] = useState(false);
const { playTrack, currentTrack, isPlaying } = usePlayer();
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
useEffect(() => {
if (!artistName) return;
setIsLoading(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// Fetch artist info (photo) and songs in parallel
Promise.all([
fetch(`${apiUrl}/api/artist/info?name=${encodeURIComponent(artistName)}`).then(r => r.json()),
fetch(`${apiUrl}/api/search?query=${encodeURIComponent(artistName)}`).then(r => r.json())
])
.then(([artistInfo, searchData]) => {
if (artistInfo.photo) {
setArtistPhoto(artistInfo.photo);
}
setAllSongs(searchData.tracks || []);
})
.catch(err => console.error("Failed to load artist:", err))
.finally(() => setIsLoading(false));
}, [artistName]);
// Categorize songs
const topSongs = allSongs.slice(0, 5); // Most popular
const hitSongs = allSongs.slice(5, 10); // More hits
const recentSongs = allSongs.slice(10, 15); // Recent-ish
const otherSongs = allSongs.slice(15); // Rest
const handlePlay = (track: Track, queue: Track[]) => {
playTrack(track, queue);
};
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
e.stopPropagation();
setTrackToAdd(track);
setIsAddToPlaylistOpen(true);
};
// Get first letter for avatar fallback
const artistInitials = artistName.substring(0, 2).toUpperCase();
if (!artistName) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-[#a7a7a7]">No artist specified</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] no-scrollbar pb-24">
{/* Artist Header */}
<div className="relative h-80 bg-gradient-to-b from-[#535353] to-transparent p-6 flex items-end">
{/* Artist Avatar - Use real photo or fallback */}
{artistPhoto ? (
<img
src={artistPhoto}
alt={artistName}
className="w-48 h-48 rounded-full object-cover shadow-2xl mr-6"
/>
) : (
<div className="w-48 h-48 rounded-full bg-gradient-to-br from-purple-600 to-blue-500 flex items-center justify-center text-white text-6xl font-bold shadow-2xl mr-6">
{artistInitials}
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium uppercase tracking-widest mb-2 flex items-center gap-2">
<User className="w-4 h-4" /> Artist
</p>
<h1 className="text-5xl md:text-7xl font-bold mb-4">{artistName}</h1>
<p className="text-[#a7a7a7]">{allSongs.length} songs found</p>
</div>
</div>
{/* Play Button */}
<div className="px-6 py-4 flex items-center gap-4">
<button
onClick={() => allSongs.length > 0 && handlePlay(allSongs[0], allSongs)}
className="w-14 h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg"
>
<Play className="fill-black text-black ml-1 w-6 h-6" />
</button>
</div>
{isLoading ? (
<div className="px-6 py-8 flex items-center justify-center">
<div className="animate-pulse text-[#a7a7a7]">Loading songs...</div>
</div>
) : allSongs.length === 0 ? (
<div className="px-6 py-8 text-center">
<p className="text-[#a7a7a7]">No songs found for this artist</p>
</div>
) : (
<div className="px-6 space-y-8">
{/* Popular / Top Songs */}
{topSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<Music className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Popular</h2>
</div>
<SongList songs={topSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* Hit Songs */}
{hitSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<span className="text-xl">🔥</span>
<h2 className="text-2xl font-bold">Hits</h2>
</div>
<SongList songs={hitSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* Recent Releases */}
{recentSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">More Songs</h2>
</div>
<SongList songs={recentSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* All Other Songs */}
{otherSongs.length > 0 && (
<section>
<button
onClick={() => setShowAllSongs(!showAllSongs)}
className="flex items-center gap-2 mb-4 text-2xl font-bold hover:underline"
>
<span>All Songs ({otherSongs.length})</span>
{showAllSongs ? <ChevronUp className="w-6 h-6" /> : <ChevronDown className="w-6 h-6" />}
</button>
{showAllSongs && (
<SongList songs={otherSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
)}
</section>
)}
</div>
)}
<AddToPlaylistModal
track={trackToAdd}
isOpen={isAddToPlaylistOpen}
onClose={() => setIsAddToPlaylistOpen(false)}
/>
</div>
);
}
// Song List Component
interface SongListProps {
songs: Track[];
allSongs: Track[];
onPlay: (track: Track, queue: Track[]) => void;
onAddToPlaylist: (e: React.MouseEvent, track: Track) => void;
currentTrack: Track | null;
isPlaying: boolean;
}
function SongList({ songs, allSongs, onPlay, onAddToPlaylist, currentTrack, isPlaying }: SongListProps) {
return (
<div className="space-y-2">
{songs.map((track, index) => {
const isCurrent = currentTrack?.id === track.id;
return (
<div
key={`${track.id}-${index}`}
onClick={() => onPlay(track, allSongs)}
className={`flex items-center gap-4 p-3 rounded-md hover:bg-[#282828] cursor-pointer group transition ${isCurrent ? 'bg-[#282828]' : ''}`}
>
{/* Track Number / Play Icon */}
<div className="w-8 text-center text-[#a7a7a7] group-hover:hidden">
{isCurrent ? (
<span className="text-[#1DB954]">{isPlaying ? '▶' : '❚❚'}</span>
) : (
<span>{index + 1}</span>
)}
</div>
<div className="w-8 text-center hidden group-hover:block">
{isCurrent && isPlaying ? (
<Pause className="w-4 h-4 text-white mx-auto" />
) : (
<Play className="w-4 h-4 text-white mx-auto" />
)}
</div>
{/* Cover */}
<div className="w-12 h-12 flex-shrink-0">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-12 h-12 rounded object-cover"
/>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<h3 className={`font-medium truncate ${isCurrent ? 'text-[#1DB954]' : 'text-white'}`}>
{track.title}
</h3>
<p className="text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
{/* Album */}
<div className="hidden md:block flex-1 min-w-0">
<p className="text-sm text-[#a7a7a7] truncate">{track.album}</p>
</div>
{/* Duration */}
<div className="text-sm text-[#a7a7a7] w-12 text-right">
{track.duration ? formatDuration(track.duration) : '--:--'}
</div>
{/* Add to Playlist */}
<button
onClick={(e) => onAddToPlaylist(e, track)}
className="p-2 opacity-0 group-hover:opacity-100 hover:bg-[#383838] rounded-full transition"
>
<Plus className="w-4 h-4 text-white" />
</button>
</div>
);
})}
</div>
);
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function ArtistPage() {
return (
<Suspense fallback={<div className="h-full flex items-center justify-center text-[#a7a7a7]">Loading...</div>}>
<ArtistPageContent />
</Suspense>
);
}