Changeset 1579b4f for frontend/src/pages/MusicalCollection.tsx
- Timestamp:
- 02/10/26 21:58:39 (5 months ago)
- Branches:
- main
- Children:
- 92db381
- Parents:
- 0808ef2
- File:
-
- 1 edited
-
frontend/src/pages/MusicalCollection.tsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/MusicalCollection.tsx
r0808ef2 r1579b4f 1 1 import { useEffect, useState } from "react"; 2 import { useNavigate, useParams } from "react-router-dom";2 import { Link, useParams } from "react-router-dom"; 3 3 import axiosInstance from "../api/axiosInstance"; 4 import LoadingSpinner from "../components/LoadingSpinner"; 5 import type { Song, Album, Playlist } from "../utils/types"; 4 import SongItem from "../components/SongItem"; 6 5 import { handleError } from "../utils/error"; 6 import type { Album, Playlist, Song } from "../utils/types"; 7 7 interface CollectionView { 8 id: number; 9 title: string; 10 genre?: string; 11 type: string; 12 releasedBy: string; 13 isLikedByCurrentUser?: boolean; 14 songs: Song[]; 8 id: number; 9 title: string; 10 cover?: string | null; 11 genre?: string; 12 type: string; 13 releasedBy: string; 14 isLikedByCurrentUser?: boolean; 15 songs: Song[]; 15 16 } 16 17 17 18 const MusicalCollection = () => { 18 const { type, id } = useParams(); 19 const navigate = useNavigate(); 20 const [collection, setCollection] = useState<CollectionView | null>(null); 21 const [isLoading, setIsLoading] = useState(true); 22 const [error, setError] = useState<string | null>(null); 23 24 const normalizeCollection = ( 25 data: Album | Playlist, 26 type: string, 27 ): CollectionView => { 28 if (type === "album") { 29 const album = data as Album; 30 return { 31 id: album.id, 32 title: album.title, 33 genre: album.genre, 34 type: album.type, 35 releasedBy: album.releasedBy, 36 isLikedByCurrentUser: album.isLikedByCurrentUser, 37 songs: album.songs, 38 }; 39 } else { 40 const playlist = data as Playlist; 41 return { 42 id: playlist.id, 43 title: playlist.name, 44 genre: undefined, 45 type: "PLAYLIST", 46 releasedBy: playlist.creatorName, 47 isLikedByCurrentUser: undefined, 48 songs: playlist.songsInPlaylist, 49 }; 50 } 51 }; 52 53 useEffect(() => { 54 const fetchData = async () => { 55 setIsLoading(true); 56 setError(null); 57 try { 58 const endpoint = 59 type === "album" ? `/albums/${id}` : `/playlists/${id}`; 60 const response = await axiosInstance.get(endpoint); 61 62 const normalized = normalizeCollection(response.data, type!); 63 setCollection(normalized); 64 } catch (err: any) { 65 setError(handleError(err)); 66 } finally { 67 setIsLoading(false); 68 } 69 }; 70 fetchData(); 71 }, [id, type]); 72 73 if (isLoading) { 74 return <LoadingSpinner />; 75 } 76 77 if (error) { 78 return ( 79 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 80 <h2 className="font-bold">Error</h2> 81 <p>{error}</p> 82 </div> 83 ); 84 } 85 86 if (!collection) return null; 87 88 return ( 89 <div className="container mx-auto p-6"> 90 <button 91 onClick={() => navigate(-1)} 92 className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 93 > 94 ← Back 95 </button> 96 97 <div className="bg-white shadow-lg rounded-lg p-8"> 98 <div className="flex items-start gap-6 mb-8"> 99 <div className="shrink-0"> 100 <div className="w-40 h-40 rounded-lg bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-5xl font-bold shadow-lg"> 101 {collection.title.charAt(0).toUpperCase()} 102 </div> 103 </div> 104 105 <div className="flex-1"> 106 <span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium mb-2"> 107 {collection.type} 108 </span> 109 <h1 className="text-4xl font-bold mb-3">{collection.title}</h1> 110 111 <div className="flex items-center gap-3 text-gray-700 mb-4"> 112 <span className="font-semibold">{collection.releasedBy}</span> 113 {collection.genre && ( 114 <> 115 <span>•</span> 116 <span className="text-gray-600">{collection.genre}</span> 117 </> 118 )} 119 {collection.songs && ( 120 <> 121 <span>•</span> 122 <span className="text-gray-600"> 123 {collection.songs.length} song 124 {collection.songs.length !== 1 ? "s" : ""} 125 </span> 126 </> 127 )} 128 </div> 129 130 {type === "album" && ( 131 <button 132 className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors duration-200" 133 aria-label={collection.isLikedByCurrentUser ? "Unlike" : "Like"} 134 > 135 <svg 136 className="w-5 h-5" 137 fill={collection.isLikedByCurrentUser ? "#ef4444" : "none"} 138 stroke={ 139 collection.isLikedByCurrentUser ? "#ef4444" : "#6b7280" 140 } 141 strokeWidth="2" 142 viewBox="0 0 24 24" 143 > 144 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 145 </svg> 146 <span className="text-sm font-medium text-gray-700"> 147 {collection.isLikedByCurrentUser ? "Liked" : "Like"} 148 </span> 149 </button> 150 )} 151 </div> 152 </div> 153 154 <div className="border-t pt-6"> 155 <h2 className="text-2xl font-bold mb-4 text-gray-800">Songs</h2> 156 157 {collection.songs && collection.songs.length > 0 ? ( 158 <div className="space-y-2"> 159 {collection.songs.map((song, index) => ( 160 <div 161 key={song.id} 162 className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-colors duration-150" 163 > 164 <span className="text-gray-500 font-medium w-8 text-center"> 165 {index + 1} 166 </span> 167 168 <div className="flex-1 min-w-0"> 169 <p className="font-semibold text-gray-900 truncate"> 170 {song.title} 171 </p> 172 <p className="text-sm text-gray-600 truncate"> 173 {song.releasedBy} 174 </p> 175 </div> 176 177 <span className="text-sm text-gray-600 px-3 py-1 bg-gray-100 rounded-full"> 178 {song.genre} 179 </span> 180 181 <button 182 className="p-2 hover:bg-gray-100 rounded-full transition-colors duration-200" 183 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"} 184 > 185 <svg 186 className="w-5 h-5" 187 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 188 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 189 strokeWidth="2" 190 viewBox="0 0 24 24" 191 > 192 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 193 </svg> 194 </button> 195 </div> 196 ))} 197 </div> 198 ) : ( 199 <p className="text-center text-gray-500 py-8">No songs available</p> 200 )} 201 </div> 202 </div> 203 </div> 204 ); 19 const { type, id } = useParams(); 20 const [collection, setCollection] = useState<CollectionView | null>(null); 21 const [isLoading, setIsLoading] = useState(true); 22 const [error, setError] = useState<string | null>(null); 23 24 const normalizeCollection = ( 25 data: Album | Playlist, 26 type: string, 27 ): CollectionView => { 28 if (type === "album") { 29 const album = data as Album; 30 return { 31 id: album.id, 32 title: album.title, 33 cover: album.cover, 34 genre: album.genre, 35 type: album.type, 36 releasedBy: album.releasedBy, 37 isLikedByCurrentUser: album.isLikedByCurrentUser, 38 songs: album.songs, 39 }; 40 } else { 41 const playlist = data as Playlist; 42 return { 43 id: playlist.id, 44 title: playlist.name, 45 cover: playlist.cover, 46 genre: undefined, 47 type: "PLAYLIST", 48 releasedBy: playlist.creatorName, 49 isLikedByCurrentUser: undefined, 50 songs: playlist.songsInPlaylist, 51 }; 52 } 53 }; 54 55 const toggleLike = async (songId: number) => { 56 try { 57 await axiosInstance.post(`/musical-entity/${songId}/like`); 58 setCollection((prev) => { 59 if (!prev) return null; 60 return { 61 ...prev, 62 songs: prev.songs.map((s) => 63 s.id === songId 64 ? { ...s, isLikedByCurrentUser: !s.isLikedByCurrentUser } 65 : s, 66 ), 67 }; 68 }); 69 } catch (err) { 70 console.error("Error toggling like:", err); 71 } 72 }; 73 74 const toggleCollectionLike = async () => { 75 if (!collection) return; 76 try { 77 await axiosInstance.post(`/musical-entity/${collection.id}/like`); 78 setCollection((prev) => { 79 if (!prev) return null; 80 return { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }; 81 }); 82 } catch (err) { 83 console.error("Error toggling collection like:", err); 84 } 85 }; 86 87 useEffect(() => { 88 const fetchData = async () => { 89 setIsLoading(true); 90 setError(null); 91 try { 92 const endpoint = 93 type === "album" ? `/albums/${id}` : `/playlists/${id}`; 94 const response = await axiosInstance.get(endpoint); 95 96 const normalized = normalizeCollection(response.data, type!); 97 setCollection(normalized); 98 } catch (err: any) { 99 setError(handleError(err)); 100 } finally { 101 setIsLoading(false); 102 } 103 }; 104 fetchData(); 105 }, [id, type]); 106 107 if (isLoading) { 108 return ( 109 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 110 <div className="flex flex-col items-center gap-4"> 111 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 112 <p className="text-gray-400 text-lg">Loading collection…</p> 113 </div> 114 </div> 115 ); 116 } 117 118 if (error) { 119 return ( 120 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 121 <div className="text-center"> 122 <p className="text-red-400 text-xl mb-4">{error}</p> 123 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 124 ← Back to Home 125 </Link> 126 </div> 127 </div> 128 ); 129 } 130 131 if (!collection) return null; 132 133 return ( 134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 135 <div className="max-w-5xl mx-auto px-6 py-10"> 136 {/* Back link */} 137 <Link 138 to="/" 139 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 140 > 141 <svg 142 className="w-4 h-4" 143 fill="none" 144 stroke="currentColor" 145 viewBox="0 0 24 24" 146 > 147 <path 148 strokeLinecap="round" 149 strokeLinejoin="round" 150 strokeWidth={2} 151 d="M15 19l-7-7 7-7" 152 /> 153 </svg> 154 Back to Home 155 </Link> 156 157 {/* Hero section */} 158 <div className="flex flex-col md:flex-row gap-8 mb-10"> 159 {/* Cover art */} 160 <div className="w-full md:w-72 shrink-0"> 161 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 162 <img 163 src={collection.cover || "/favicon.png"} 164 alt={collection.title} 165 className="absolute inset-0 w-full h-full object-cover" 166 onError={(e) => { 167 (e.target as HTMLImageElement).src = "/favicon.png"; 168 }} 169 /> 170 </div> 171 </div> 172 173 {/* Collection info */} 174 <div className="flex flex-col justify-end gap-3 min-w-0"> 175 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 176 {collection.genre ? `${collection.genre} • ` : ""} 177 {collection.type === "PLAYLIST" ? "Playlist" : "Album"} 178 </span> 179 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 180 {collection.title} 181 </h1> 182 183 <p className="text-xl text-gray-300 font-semibold"> 184 {collection.releasedBy} 185 </p> 186 187 {collection.songs && ( 188 <p className="text-sm text-gray-500"> 189 {collection.songs.length} song 190 {collection.songs.length !== 1 ? "s" : ""} 191 </p> 192 )} 193 194 {/* Action buttons */} 195 <div className="flex items-center gap-3 mt-4"> 196 {type === "album" && ( 197 <button 198 onClick={toggleCollectionLike} 199 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 200 collection.isLikedByCurrentUser 201 ? "bg-[#1db954] text-black" 202 : "bg-white/10 text-white hover:bg-white/20" 203 }`} 204 > 205 <svg 206 className="w-5 h-5" 207 fill={ 208 collection.isLikedByCurrentUser ? "currentColor" : "none" 209 } 210 stroke="currentColor" 211 viewBox="0 0 24 24" 212 > 213 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 214 </svg> 215 {collection.isLikedByCurrentUser ? "Liked" : "Like"} 216 </button> 217 )} 218 </div> 219 </div> 220 </div> 221 222 {/* Songs list */} 223 <div className="border-t border-white/10 pt-6"> 224 <h2 className="text-2xl font-bold mb-4">Songs</h2> 225 226 {collection.songs && collection.songs.length > 0 ? ( 227 <div className="space-y-1"> 228 {collection.songs.map((song, index) => ( 229 <SongItem 230 key={song.id} 231 song={song} 232 index={index + 1} 233 onLikeToggle={() => toggleLike(song.id)} 234 /> 235 ))} 236 </div> 237 ) : ( 238 <div className="text-center py-12 text-gray-400"> 239 <p className="text-lg">No songs available</p> 240 </div> 241 )} 242 </div> 243 </div> 244 </div> 245 ); 205 246 }; 206 247
Note:
See TracChangeset
for help on using the changeset viewer.
