- Timestamp:
- 02/14/26 18:21:18 (5 months ago)
- Branches:
- main
- Children:
- ce45c7a
- Parents:
- f5bc95e
- Location:
- frontend/src
- Files:
-
- 2 added
- 7 edited
-
components/Sidebar.tsx (modified) (1 diff)
-
components/playlist/CreatePlaylistModal.tsx (added)
-
components/userProfile/ListenerView.tsx (modified) (1 diff)
-
context/playlistContext.tsx (added)
-
main.tsx (modified) (1 diff)
-
pages/MusicalCollection.tsx (modified) (1 diff)
-
pages/UserDetail.tsx (modified) (2 diffs)
-
utils/error.ts (modified) (1 diff)
-
utils/types.ts (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/components/Sidebar.tsx
rf5bc95e r85512ff 1 1 import { useEffect, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 import { toast } from "react-toastify"; 3 4 import axiosInstance, { baseURL } from "../api/axiosInstance"; 4 5 import { useAuth } from "../context/authContext"; 5 6 import { usePlayer } from "../context/playerContext"; 6 import type { Basic Playlist, BasicSong, SidebarProps } from "../utils/types";7 import type { BasicSong, SidebarProps } from "../utils/types"; 7 8 import { toEmbedUrl } from "../utils/utils"; 9 import { useCreatedPlaylists } from "../context/playlistContext"; 10 import CreatePlaylistModal from "./playlist/CreatePlaylistModal"; 11 import { getErrorMessage } from "../utils/error"; 8 12 9 13 const Sidebar = ({ isOpen, onClose }: SidebarProps) => { 10 const { user } = useAuth(); 11 const navigate = useNavigate(); 12 const { play, currentSong } = usePlayer(); 13 const [recentlyListened, setRecentlyListened] = useState<BasicSong[]>([]); 14 const [playlists, setPlaylists] = useState<BasicPlaylist[]>([]); 15 16 useEffect(() => { 17 const fetchData = async () => { 18 try { 19 const data = await axiosInstance.get<BasicSong[]>("/songs/recent"); 20 setRecentlyListened(data.data); 21 } catch (error) { 22 console.error("Error fetching recently listened songs:", error); 23 // todo: show toast 24 } 25 try { 26 const data = 27 await axiosInstance.get<BasicPlaylist[]>("/playlists/user"); 28 setPlaylists(data.data); 29 } catch (error) { 30 console.error("Error fetching playlists:", error); 31 // todo: show toast 32 } 33 }; 34 if (user) { 35 fetchData(); 36 } else { 37 setRecentlyListened([]); 38 setPlaylists([]); 39 } 40 }, [user]); 41 return ( 42 <div 43 className={`fixed left-0 top-0 h-full bg-[#121212] border-r border-white/10 transition-transform duration-300 ease-in-out z-40 ${ 44 isOpen ? "translate-x-0" : "-translate-x-full" 45 } w-64 overflow-y-auto`} 46 > 47 <div className="p-6"> 48 {/* Sidebar Header */} 49 <div className="flex justify-between items-center mb-6 pt-2"> 50 <h2 className="text-xl font-bold text-white">Library</h2> 51 <button 52 onClick={onClose} 53 className="text-gray-400 hover:text-white transition-colors" 54 aria-label="Close sidebar" 55 > 56 <svg 57 className="w-6 h-6" 58 fill="none" 59 stroke="currentColor" 60 viewBox="0 0 24 24" 61 > 62 <path 63 strokeLinecap="round" 64 strokeLinejoin="round" 65 strokeWidth={2} 66 d="M6 18L18 6M6 6l12 12" 67 /> 68 </svg> 69 </button> 70 </div> 71 72 {/* Recently Listened */} 73 <div className="mb-8"> 74 <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> 75 Recently Played 76 </h3> 77 <div className="space-y-3"> 78 {recentlyListened.map((song) => ( 79 <div 80 key={song.id} 81 className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group relative" 82 > 83 <img 84 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 85 alt={song.title} 86 className="w-10 h-10 rounded object-cover" 87 onError={(e) => { 88 (e.target as HTMLImageElement).src = "/favicon.png"; 89 }} 90 /> 91 <div className="flex-1 min-w-0"> 92 <p 93 onClick={() => navigate(`/songs/${song.id}`)} 94 className="text-sm font-medium text-white truncate hover:underline cursor-pointer" 95 > 96 {song.title} 97 </p> 98 <div className="flex items-center gap-1 text-xs text-gray-400"> 99 <span 100 onClick={(e) => { 101 e.stopPropagation(); 102 if (song.artistUsername) { 103 navigate(`/users/${song.artistUsername}`); 104 } 105 }} 106 className={`truncate ${ 107 song.artistUsername 108 ? "hover:underline cursor-pointer hover:text-white" 109 : "" 110 }`} 111 > 112 {song.artist} 113 </span> 114 {song.album && ( 115 <> 116 <span>•</span> 117 <span 118 onClick={(e) => { 119 e.stopPropagation(); 120 if (song.albumId) { 121 navigate(`/collection/album/${song.albumId}`); 122 } 123 }} 124 className={`truncate ${ 125 song.albumId 126 ? "hover:underline cursor-pointer hover:text-white" 127 : "" 128 }`} 129 > 130 {song.album} 131 </span> 132 </> 133 )} 134 </div> 135 </div> 136 {song.link && ( 137 <button 138 onClick={(e) => { 139 e.stopPropagation(); 140 play({ 141 id: song.id, 142 title: song.title, 143 artist: song.artist, 144 cover: song.cover, 145 embedUrl: toEmbedUrl(song.link!), 146 }); 147 }} 148 className={`p-1.5 rounded-full transition-all opacity-0 group-hover:opacity-100 ${ 149 currentSong?.id === song.id 150 ? "bg-white text-[#1db954]" 151 : "bg-[#1db954] text-black hover:scale-110" 152 }`} 153 aria-label={ 154 currentSong?.id === song.id ? "Now playing" : "Play song" 155 } 156 > 157 {currentSong?.id === song.id ? ( 158 <svg 159 className="w-4 h-4" 160 fill="currentColor" 161 viewBox="0 0 24 24" 162 > 163 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 164 </svg> 165 ) : ( 166 <svg 167 className="w-4 h-4" 168 fill="currentColor" 169 viewBox="0 0 24 24" 170 > 171 <path d="M8 5v14l11-7z" /> 172 </svg> 173 )} 174 </button> 175 )} 176 </div> 177 ))} 178 </div> 179 </div> 180 181 {/* Playlists */} 182 <div> 183 <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> 184 Your Playlists 185 </h3> 186 <div className="space-y-2"> 187 {playlists.map((playlist) => ( 188 <div 189 key={playlist.id} 190 className="flex items-center justify-between p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors" 191 > 192 <div className="flex items-center gap-3"> 193 <div className="w-10 h-10 bg-linear-to-br from-purple-500 to-pink-500 rounded flex items-center justify-center"> 194 <svg 195 className="w-5 h-5 text-white" 196 fill="currentColor" 197 viewBox="0 0 20 20" 198 > 199 <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 200 </svg> 201 </div> 202 <div> 203 <p className="text-sm font-medium text-white"> 204 {playlist.name} 205 </p> 206 <p className="text-xs text-gray-400"> 207 {playlist.songCount} songs 208 </p> 209 </div> 210 </div> 211 </div> 212 ))} 213 </div> 214 </div> 215 </div> 216 </div> 217 ); 14 const { user } = useAuth(); 15 const { 16 createdPlaylists, 17 isLoading: playlistsLoading, 18 refreshPlaylists, 19 } = useCreatedPlaylists(); 20 const navigate = useNavigate(); 21 const { play, currentSong } = usePlayer(); 22 const [recentlyListened, setRecentlyListened] = useState<BasicSong[]>([]); 23 const [songsLoading, setSongsLoading] = useState(false); 24 const [isModalOpen, setIsModalOpen] = useState(false); 25 26 useEffect(() => { 27 const fetchData = async () => { 28 setSongsLoading(true); 29 try { 30 const data = await axiosInstance.get<BasicSong[]>("/songs/recent"); 31 setRecentlyListened(data.data); 32 } catch (error) { 33 toast.error(getErrorMessage(error)); 34 } finally { 35 setSongsLoading(false); 36 } 37 }; 38 if (user) { 39 fetchData(); 40 } else { 41 setRecentlyListened([]); 42 setSongsLoading(false); 43 } 44 }, [user]); 45 46 const handleCreatePlaylist = async (playlistName: string) => { 47 try { 48 await axiosInstance.post("/playlists", { name: playlistName }); 49 toast.success("Playlist created successfully!"); 50 await refreshPlaylists(); 51 } catch (error) { 52 toast.error(getErrorMessage(error)); 53 } 54 }; 55 56 const isLoading = songsLoading || playlistsLoading; 57 58 return ( 59 <> 60 <div 61 className={`fixed left-0 top-0 h-full bg-[#121212] border-r border-white/10 transition-transform duration-300 ease-in-out z-40 ${ 62 isOpen ? "translate-x-0" : "-translate-x-full" 63 } w-64 overflow-y-auto`} 64 > 65 <div className="p-6"> 66 {/* Sidebar Header */} 67 <div className="flex justify-between items-center mb-6 pt-2"> 68 <h2 className="text-xl font-bold text-white">Library</h2> 69 <button 70 onClick={onClose} 71 className="text-gray-400 hover:text-white transition-colors" 72 aria-label="Close sidebar" 73 > 74 <svg 75 className="w-6 h-6" 76 fill="none" 77 stroke="currentColor" 78 viewBox="0 0 24 24" 79 > 80 <path 81 strokeLinecap="round" 82 strokeLinejoin="round" 83 strokeWidth={2} 84 d="M6 18L18 6M6 6l12 12" 85 /> 86 </svg> 87 </button> 88 </div> 89 90 {/* Loading State */} 91 {isLoading ? ( 92 <div className="flex items-center justify-center py-16"> 93 <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#1db954]"></div> 94 </div> 95 ) : ( 96 <> 97 {/* Recently Listened */} 98 <div className="mb-8"> 99 <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> 100 Recently Played 101 </h3> 102 <div className="space-y-3"> 103 {recentlyListened.map((song) => ( 104 <div 105 key={song.id} 106 className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group relative" 107 > 108 <img 109 src={ 110 song.cover 111 ? `${baseURL}/${song.cover}` 112 : "/favicon.png" 113 } 114 alt={song.title} 115 className="w-10 h-10 rounded object-cover" 116 onError={(e) => { 117 (e.target as HTMLImageElement).src = "/favicon.png"; 118 }} 119 /> 120 <div className="flex-1 min-w-0"> 121 <p 122 onClick={() => navigate(`/songs/${song.id}`)} 123 className="text-sm font-medium text-white truncate hover:underline cursor-pointer" 124 > 125 {song.title} 126 </p> 127 <div className="flex items-center gap-1 text-xs text-gray-400"> 128 <span 129 onClick={(e) => { 130 e.stopPropagation(); 131 if (song.artistUsername) { 132 navigate(`/users/${song.artistUsername}`); 133 } 134 }} 135 className={`truncate ${ 136 song.artistUsername 137 ? "hover:underline cursor-pointer hover:text-white" 138 : "" 139 }`} 140 > 141 {song.artist} 142 </span> 143 {song.album && ( 144 <> 145 <span>•</span> 146 <span 147 onClick={(e) => { 148 e.stopPropagation(); 149 if (song.albumId) { 150 navigate( 151 `/collection/album/${song.albumId}`, 152 ); 153 } 154 }} 155 className={`truncate ${ 156 song.albumId 157 ? "hover:underline cursor-pointer hover:text-white" 158 : "" 159 }`} 160 > 161 {song.album} 162 </span> 163 </> 164 )} 165 </div> 166 </div> 167 {song.link && ( 168 <button 169 onClick={(e) => { 170 e.stopPropagation(); 171 play({ 172 id: song.id, 173 title: song.title, 174 artist: song.artist, 175 cover: song.cover, 176 embedUrl: toEmbedUrl(song.link!), 177 }); 178 }} 179 className={`p-1.5 rounded-full transition-all opacity-0 group-hover:opacity-100 ${ 180 currentSong?.id === song.id 181 ? "bg-white text-[#1db954]" 182 : "bg-[#1db954] text-black hover:scale-110" 183 }`} 184 aria-label={ 185 currentSong?.id === song.id 186 ? "Now playing" 187 : "Play song" 188 } 189 > 190 {currentSong?.id === song.id ? ( 191 <svg 192 className="w-4 h-4" 193 fill="currentColor" 194 viewBox="0 0 24 24" 195 > 196 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 197 </svg> 198 ) : ( 199 <svg 200 className="w-4 h-4" 201 fill="currentColor" 202 viewBox="0 0 24 24" 203 > 204 <path d="M8 5v14l11-7z" /> 205 </svg> 206 )} 207 </button> 208 )} 209 </div> 210 ))} 211 </div> 212 </div> 213 214 {/* Playlists */} 215 <div> 216 <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> 217 Your Playlists 218 </h3> 219 <div className="space-y-2"> 220 {createdPlaylists?.map((playlist) => ( 221 <div 222 key={playlist.id} 223 onClick={() => { 224 navigate(`/collection/playlist/${playlist.id}`); 225 }} 226 className="flex items-center justify-between p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors" 227 > 228 <div className="flex items-center gap-3"> 229 <div className="w-10 h-10 bg-linear-to-br from-purple-500 to-pink-500 rounded flex items-center justify-center"> 230 <svg 231 className="w-5 h-5 text-white" 232 fill="currentColor" 233 viewBox="0 0 20 20" 234 > 235 <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 236 </svg> 237 </div> 238 <div> 239 <p className="text-sm font-medium text-white"> 240 {playlist.name} 241 </p> 242 <p className="text-xs text-gray-400"> 243 {playlist.songCount} songs 244 </p> 245 </div> 246 </div> 247 </div> 248 ))} 249 250 {/* Create Playlist Button */} 251 <button 252 onClick={() => setIsModalOpen(true)} 253 className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group mt-2" 254 > 255 <div className="w-10 h-10 bg-[#282828] group-hover:bg-[#3a3a3a] rounded flex items-center justify-center transition-colors"> 256 <svg 257 className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" 258 fill="none" 259 stroke="currentColor" 260 viewBox="0 0 24 24" 261 > 262 <path 263 strokeLinecap="round" 264 strokeLinejoin="round" 265 strokeWidth={2} 266 d="M12 4v16m8-8H4" 267 /> 268 </svg> 269 </div> 270 <p className="text-sm font-medium text-gray-400 group-hover:text-white transition-colors"> 271 Create Playlist 272 </p> 273 </button> 274 </div> 275 </div> 276 </> 277 )} 278 </div> 279 </div> 280 281 <CreatePlaylistModal 282 isOpen={isModalOpen} 283 onClose={() => setIsModalOpen(false)} 284 onSubmit={handleCreatePlaylist} 285 /> 286 </> 287 ); 218 288 }; 219 289 -
frontend/src/components/userProfile/ListenerView.tsx
rf5bc95e r85512ff 1 1 import { Album, Bookmark, Heart, ListMusic, Music } from "lucide-react"; 2 2 import { useState } from "react"; 3 import { useNavigate } from "react-router-dom";3 import { useNavigate, useParams } from "react-router-dom"; 4 4 import { toast } from "react-toastify"; 5 5 import axiosInstance, { baseURL } from "../../api/axiosInstance"; 6 6 import type { MusicalEntity, Playlist } from "../../utils/types"; 7 7 import SongItem from "../SongItem"; 8 import { useAuth } from "../../context/authContext"; 8 9 9 10 interface ListenerViewProps { 10 likedEntities: MusicalEntity[] | [];11 createdPlaylists: Playlist[] | [];12 savedPlaylists: Playlist[] | [];11 likedEntities: MusicalEntity[] | []; 12 createdPlaylists: Playlist[] | []; 13 savedPlaylists: Playlist[] | []; 13 14 } 14 15 15 16 const ListenerView = ({ 16 likedEntities,17 createdPlaylists,18 savedPlaylists,17 likedEntities, 18 createdPlaylists, 19 savedPlaylists, 19 20 }: ListenerViewProps) => { 20 const navigate = useNavigate(); 21 const [items, setItems] = useState(likedEntities); 22 const [savedItems, setSavedItems] = useState(savedPlaylists); 23 24 const likedSongs = items.filter((e) => e.type === "SONG"); 25 const likedAlbums = items.filter((e) => e.type === "ALBUM"); 26 27 const handleSavePlaylist = async ( 28 e: React.MouseEvent, 29 playlistId: number, 30 playlistName: string, 31 ) => { 32 e.stopPropagation(); 33 34 try { 35 const response = await axiosInstance.post(`/playlist/${playlistId}/save`); 36 const data = response.data; 37 38 setSavedItems((prevItems) => 39 data.isSaved 40 ? [...prevItems, prevItems.find((p) => p.id === playlistId)!] 41 : prevItems.filter((p) => p.id !== playlistId), 42 ); 43 44 toast.success( 45 data.isSaved 46 ? `Saved "${playlistName}" to your library` 47 : `Removed "${playlistName}" from your library`, 48 ); 49 } catch (err: any) { 50 toast.error(err.response?.data?.error || "Failed to save the playlist"); 51 } 52 }; 53 54 const handleLike = async (id: number, title: string) => { 55 try { 56 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 57 const data = response.data; 58 59 setItems((prevItems) => 60 prevItems.map((item) => 61 item.id === data.entityId 62 ? { ...item, isLikedByCurrentUser: data.isLiked } 63 : item, 64 ), 65 ); 66 67 toast.success( 68 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 69 ); 70 } catch (err: any) { 71 toast.error(err.response?.data?.error || "Failed to like the item"); 72 } 73 }; 74 75 return ( 76 <div className="mt-8 space-y-12"> 77 {createdPlaylists && createdPlaylists.length > 0 && ( 78 <section> 79 <div className="flex items-center gap-3 mb-6"> 80 <ListMusic className="w-6 h-6 text-[#1db954]" /> 81 <h3 className="text-2xl font-bold text-white">Created Playlists</h3> 82 <span className="text-sm text-gray-500"> 83 ({createdPlaylists.length}) 84 </span> 85 </div> 86 87 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 88 {createdPlaylists.map((playlist) => ( 89 <div 90 key={playlist.id} 91 className="group cursor-pointer" 92 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 93 > 94 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 95 <img 96 src={ 97 playlist.cover 98 ? `${baseURL}/${playlist.cover}` 99 : "/favicon.png" 100 } 101 alt={playlist.name} 102 className="w-full h-full object-cover" 103 onError={(e) => { 104 (e.target as HTMLImageElement).src = "/favicon.png"; 105 }} 106 /> 107 108 <button 109 onClick={(e) => 110 handleSavePlaylist(e, playlist.id, playlist.name) 111 } 112 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 113 title={playlist.isSavedByCurrentUser ? "Unsave" : "Save"} 114 > 115 <Bookmark 116 className={`w-5 h-5 ${ 117 playlist.isSavedByCurrentUser 118 ? "fill-[#1db954] text-[#1db954]" 119 : "text-gray-400" 120 }`} 121 /> 122 </button> 123 </div> 124 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 125 {playlist.name} 126 </p> 127 <p className="text-xs text-gray-400 truncate"> 128 {playlist.creatorName} 129 </p> 130 </div> 131 ))} 132 </div> 133 </section> 134 )} 135 136 {savedItems && savedItems.length > 0 && ( 137 <section> 138 <div className="flex items-center gap-3 mb-6"> 139 <Bookmark className="w-6 h-6 text-[#1db954] fill-[#1db954]" /> 140 <h3 className="text-2xl font-bold text-white">Saved Playlists</h3> 141 <span className="text-sm text-gray-500">({savedItems.length})</span> 142 </div> 143 144 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 145 {savedItems.map((playlist) => ( 146 <div 147 key={playlist.id} 148 className="group cursor-pointer" 149 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 150 > 151 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 152 <img 153 src={ 154 playlist.cover 155 ? `${baseURL}/${playlist.cover}` 156 : "/favicon.png" 157 } 158 alt={playlist.name} 159 className="w-full h-full object-cover" 160 onError={(e) => { 161 (e.target as HTMLImageElement).src = "/favicon.png"; 162 }} 163 /> 164 165 <button 166 onClick={(e) => 167 handleSavePlaylist(e, playlist.id, playlist.name) 168 } 169 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100" 170 title="Unsave" 171 > 172 <Bookmark className="w-5 h-5 fill-[#1db954] text-[#1db954]" /> 173 </button> 174 </div> 175 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 176 {playlist.name} 177 </p> 178 <p className="text-xs text-gray-400 truncate"> 179 {playlist.creatorName} 180 </p> 181 </div> 182 ))} 183 </div> 184 </section> 185 )} 186 187 {likedSongs && likedSongs.length > 0 && ( 188 <section> 189 <div className="flex items-center gap-3 mb-6"> 190 <Heart className="w-6 h-6 text-red-500 fill-red-500" /> 191 <h3 className="text-2xl font-bold text-white">Liked Songs</h3> 192 <span className="text-sm text-gray-500"> 193 ({likedSongs?.length}) 194 </span> 195 </div> 196 197 <div className="space-y-1"> 198 {likedSongs.map((song, index) => ( 199 <SongItem 200 key={song.id} 201 song={{ 202 id: song.id, 203 title: song.title, 204 cover: song.cover 205 ? `${baseURL}/${song.cover}` 206 : "/favicon.png", 207 genre: song.genre, 208 link: (song as any).link, 209 releasedBy: song.releasedBy, 210 isLikedByCurrentUser: song.isLikedByCurrentUser, 211 }} 212 index={index + 1} 213 onLikeToggle={() => handleLike(song.id, song.title)} 214 /> 215 ))} 216 </div> 217 </section> 218 )} 219 220 {likedAlbums && likedAlbums.length > 0 && ( 221 <section> 222 <div className="flex items-center gap-3 mb-6"> 223 <Album className="w-6 h-6 text-[#1db954]" /> 224 <h3 className="text-2xl font-bold text-white">Liked Albums</h3> 225 <span className="text-sm text-gray-500"> 226 ({likedAlbums.length}) 227 </span> 228 </div> 229 230 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 231 {likedAlbums.map((album) => ( 232 <div 233 key={album.id} 234 className="group cursor-pointer" 235 onClick={() => navigate(`/collection/album/${album.id}`)} 236 > 237 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 238 <img 239 src={ 240 album.cover ? `${baseURL}/${album.cover}` : "/favicon.png" 241 } 242 alt={album.title} 243 className="w-full h-full object-cover" 244 onError={(e) => { 245 (e.target as HTMLImageElement).src = "/favicon.png"; 246 }} 247 /> 248 249 <button 250 onClick={(e) => { 251 e.stopPropagation(); 252 handleLike(album.id, album.title); 253 }} 254 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 255 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 256 > 257 <svg 258 className="w-5 h-5" 259 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 260 stroke={ 261 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af" 262 } 263 strokeWidth="2" 264 viewBox="0 0 24 24" 265 > 266 <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" /> 267 </svg> 268 </button> 269 </div> 270 <p className="font-semibold text-sm text-white truncate group-hover:text-[#1db954] transition-colors"> 271 {album.title} 272 </p> 273 <p className="text-xs text-gray-400 truncate mt-1"> 274 {album.releasedBy} 275 </p> 276 </div> 277 ))} 278 </div> 279 </section> 280 )} 281 282 {likedEntities && 283 likedEntities.length === 0 && 284 (!createdPlaylists || createdPlaylists.length === 0) && 285 (!savedItems || savedItems.length === 0) && ( 286 <div className="flex flex-col items-center justify-center py-16 text-gray-500"> 287 <Music className="w-20 h-20 mb-4 opacity-20" /> 288 <p className="text-lg font-medium">Nothing here yet</p> 289 <p className="text-sm mt-2"> 290 Start exploring music to build your collection 291 </p> 292 </div> 293 )} 294 </div> 295 ); 21 const navigate = useNavigate(); 22 const { username: usernameParam } = useParams(); 23 const { user: currentUser } = useAuth(); 24 const [items, setItems] = useState(likedEntities); 25 const [createdItems, setCreatedItems] = useState(createdPlaylists); 26 const [savedItems, setSavedItems] = useState(savedPlaylists); 27 28 const likedSongs = items.filter((e) => e.type === "SONG"); 29 const likedAlbums = items.filter((e) => e.type === "ALBUM"); 30 const username = usernameParam || currentUser?.username; 31 const isOwnProfile = currentUser?.username === username; 32 33 const handleSavePlaylist = async ( 34 e: React.MouseEvent, 35 playlistId: number, 36 playlistName: string, 37 ) => { 38 e.stopPropagation(); 39 40 try { 41 const response = await axiosInstance.post( 42 `/playlists/${playlistId}/save`, 43 ); 44 const data = response.data; 45 46 setCreatedItems((prevItems) => 47 prevItems.map((p) => 48 p.id === playlistId 49 ? { ...p, isSavedByCurrentUser: data.isSavedByCurrentUser } 50 : p, 51 ), 52 ); 53 54 setSavedItems((prevItems) => 55 prevItems.map((p) => 56 p.id === playlistId 57 ? { ...p, isSavedByCurrentUser: data.isSavedByCurrentUser } 58 : p, 59 ), 60 ); 61 62 if (isOwnProfile) { 63 if (data.isSavedByCurrentUser) { 64 const playlistToAdd = createdItems.find((p) => p.id === playlistId); 65 if (playlistToAdd && !savedItems.find((p) => p.id === playlistId)) { 66 setSavedItems((prevItems) => [ 67 ...prevItems, 68 { ...playlistToAdd, isSavedByCurrentUser: true }, 69 ]); 70 } 71 } else { 72 setSavedItems((prevItems) => 73 prevItems.filter((p) => p.id !== playlistId), 74 ); 75 } 76 } 77 78 toast.success( 79 data.isSavedByCurrentUser 80 ? `Saved "${playlistName}" to your library` 81 : `Removed "${playlistName}" from your library`, 82 ); 83 } catch (err: any) { 84 toast.error(err.response?.data?.error || "Failed to save the playlist"); 85 } 86 }; 87 88 const handleLike = async (id: number, title: string) => { 89 try { 90 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 91 const data = response.data; 92 93 setItems((prevItems) => 94 prevItems.map((item) => 95 item.id === data.entityId 96 ? { ...item, isLikedByCurrentUser: data.isLiked } 97 : item, 98 ), 99 ); 100 101 toast.success( 102 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 103 ); 104 } catch (err: any) { 105 toast.error(err.response?.data?.error || "Failed to like the item"); 106 } 107 }; 108 109 return ( 110 <div className="mt-8 space-y-12"> 111 {createdItems && createdItems.length > 0 && ( 112 <section> 113 <div className="flex items-center gap-3 mb-6"> 114 <ListMusic className="w-6 h-6 text-[#1db954]" /> 115 <h3 className="text-2xl font-bold text-white">Created Playlists</h3> 116 <span className="text-sm text-gray-500"> 117 ({createdItems.length}) 118 </span> 119 </div> 120 121 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 122 {createdItems.map((playlist) => ( 123 <div 124 key={playlist.id} 125 className="group cursor-pointer" 126 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 127 > 128 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 129 <img 130 src={ 131 playlist.cover 132 ? `${baseURL}/${playlist.cover}` 133 : "/favicon.png" 134 } 135 alt={playlist.name} 136 className="w-full h-full object-cover" 137 onError={(e) => { 138 (e.target as HTMLImageElement).src = "/favicon.png"; 139 }} 140 /> 141 {!isOwnProfile && 142 currentUser?.username != playlist.creatorUsername && ( 143 <button 144 onClick={(e) => 145 handleSavePlaylist(e, playlist.id, playlist.name) 146 } 147 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 148 title={ 149 playlist.isSavedByCurrentUser ? "Unsave" : "Save" 150 } 151 > 152 <Bookmark 153 className={`w-5 h-5 ${ 154 playlist.isSavedByCurrentUser 155 ? "fill-[#1db954] text-[#1db954]" 156 : "text-gray-400" 157 }`} 158 /> 159 </button> 160 )} 161 </div> 162 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 163 {playlist.name} 164 </p> 165 <p className="text-xs text-gray-400 truncate"> 166 {playlist.creatorName} 167 </p> 168 </div> 169 ))} 170 </div> 171 </section> 172 )} 173 174 {savedItems && savedItems.length > 0 && ( 175 <section> 176 <div className="flex items-center gap-3 mb-6"> 177 <Bookmark className="w-6 h-6 text-[#1db954] fill-[#1db954]" /> 178 <h3 className="text-2xl font-bold text-white">Saved Playlists</h3> 179 <span className="text-sm text-gray-500">({savedItems.length})</span> 180 </div> 181 182 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 183 {savedItems.map((playlist) => ( 184 <div 185 key={playlist.id} 186 className="group cursor-pointer" 187 onClick={() => navigate(`/collection/playlist/${playlist.id}`)} 188 > 189 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 190 <img 191 src={ 192 playlist.cover 193 ? `${baseURL}/${playlist.cover}` 194 : "/favicon.png" 195 } 196 alt={playlist.name} 197 className="w-full h-full object-cover" 198 onError={(e) => { 199 (e.target as HTMLImageElement).src = "/favicon.png"; 200 }} 201 /> 202 203 {currentUser?.username != playlist.creatorUsername && ( 204 <button 205 onClick={(e) => 206 handleSavePlaylist(e, playlist.id, playlist.name) 207 } 208 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 209 title={playlist.isSavedByCurrentUser ? "Unsave" : "Save"} 210 > 211 <Bookmark 212 className={`w-5 h-5 ${ 213 playlist.isSavedByCurrentUser 214 ? "fill-[#1db954] text-[#1db954]" 215 : "text-gray-400" 216 }`} 217 /> 218 </button> 219 )} 220 </div> 221 <p className="text-sm font-semibold text-white truncate group-hover:text-[#1db954] transition-colors"> 222 {playlist.name} 223 </p> 224 <p className="text-xs text-gray-400 truncate"> 225 {playlist.creatorName} 226 </p> 227 </div> 228 ))} 229 </div> 230 </section> 231 )} 232 233 {likedSongs && likedSongs.length > 0 && ( 234 <section> 235 <div className="flex items-center gap-3 mb-6"> 236 <Heart className="w-6 h-6 text-red-500 fill-red-500" /> 237 <h3 className="text-2xl font-bold text-white">Liked Songs</h3> 238 <span className="text-sm text-gray-500"> 239 ({likedSongs?.length}) 240 </span> 241 </div> 242 243 <div className="space-y-1"> 244 {likedSongs.map((song, index) => ( 245 <SongItem 246 key={song.id} 247 song={{ 248 id: song.id, 249 title: song.title, 250 cover: song.cover 251 ? `${baseURL}/${song.cover}` 252 : "/favicon.png", 253 genre: song.genre, 254 link: (song as any).link, 255 releasedBy: song.releasedBy, 256 isLikedByCurrentUser: song.isLikedByCurrentUser, 257 }} 258 index={index + 1} 259 onLikeToggle={() => handleLike(song.id, song.title)} 260 /> 261 ))} 262 </div> 263 </section> 264 )} 265 266 {likedAlbums && likedAlbums.length > 0 && ( 267 <section> 268 <div className="flex items-center gap-3 mb-6"> 269 <Album className="w-6 h-6 text-[#1db954]" /> 270 <h3 className="text-2xl font-bold text-white">Liked Albums</h3> 271 <span className="text-sm text-gray-500"> 272 ({likedAlbums.length}) 273 </span> 274 </div> 275 276 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 277 {likedAlbums.map((album) => ( 278 <div 279 key={album.id} 280 className="group cursor-pointer" 281 onClick={() => navigate(`/collection/album/${album.id}`)} 282 > 283 <div className="relative aspect-square rounded-lg overflow-hidden bg-[#181818] mb-3 shadow-md group-hover:shadow-xl transition-all"> 284 <img 285 src={ 286 album.cover ? `${baseURL}/${album.cover}` : "/favicon.png" 287 } 288 alt={album.title} 289 className="w-full h-full object-cover" 290 onError={(e) => { 291 (e.target as HTMLImageElement).src = "/favicon.png"; 292 }} 293 /> 294 295 <button 296 onClick={(e) => { 297 e.stopPropagation(); 298 handleLike(album.id, album.title); 299 }} 300 className="absolute top-2 right-2 p-2 bg-black/70 hover:bg-black/90 rounded-full shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100 cursor-pointer" 301 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 302 > 303 <svg 304 className="w-5 h-5" 305 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 306 stroke={ 307 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af" 308 } 309 strokeWidth="2" 310 viewBox="0 0 24 24" 311 > 312 <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" /> 313 </svg> 314 </button> 315 </div> 316 <p className="font-semibold text-sm text-white truncate group-hover:text-[#1db954] transition-colors"> 317 {album.title} 318 </p> 319 <p className="text-xs text-gray-400 truncate mt-1"> 320 {album.releasedBy} 321 </p> 322 </div> 323 ))} 324 </div> 325 </section> 326 )} 327 328 {likedEntities && 329 likedEntities.length === 0 && 330 (!createdItems || createdItems.length === 0) && 331 (!savedItems || savedItems.length === 0) && ( 332 <div className="flex flex-col items-center justify-center py-16 text-gray-500"> 333 <Music className="w-20 h-20 mb-4 opacity-20" /> 334 <p className="text-lg font-medium">Nothing here yet</p> 335 <p className="text-sm mt-2"> 336 Start exploring music to build your collection 337 </p> 338 </div> 339 )} 340 </div> 341 ); 296 342 }; 297 343 -
frontend/src/main.tsx
rf5bc95e r85512ff 5 5 import { PlayerProvider } from "./context/playerContext.tsx"; 6 6 import "./index.css"; 7 import PlaylistProvider from "./context/playlistContext.tsx"; 7 8 8 9 createRoot(document.getElementById("root")!).render( 9 <StrictMode> 10 <AuthProvider> 11 <PlayerProvider> 12 <App /> 13 </PlayerProvider> 14 </AuthProvider> 15 </StrictMode>, 10 <StrictMode> 11 <AuthProvider> 12 <PlayerProvider> 13 <PlaylistProvider> 14 <App /> 15 </PlaylistProvider> 16 </PlayerProvider> 17 </AuthProvider> 18 </StrictMode>, 16 19 ); -
frontend/src/pages/MusicalCollection.tsx
rf5bc95e r85512ff 3 3 import axiosInstance, { baseURL } from "../api/axiosInstance"; 4 4 import SongItem from "../components/SongItem"; 5 import { handleError} from "../utils/error";5 import { getErrorMessage } from "../utils/error"; 6 6 import type { Album, Playlist, Song } from "../utils/types"; 7 7 interface CollectionView { 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[];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[]; 16 16 } 17 17 18 18 const MusicalCollection = () => { 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 === songId64 ? { ...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 Home125 </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 <Link138 to="/"139 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"140 >141 <svg142 className="w-4 h-4"143 fill="none"144 stroke="currentColor"145 viewBox="0 0 24 24"146 >147 <path148 strokeLinecap="round"149 strokeLinejoin="round"150 strokeWidth={2}151 d="M15 19l-7-7 7-7"152 />153 </svg>154 Back to Home155 </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 <img163 src={164 collection.cover165 ? `${baseURL}/${collection.cover}`166 : "/favicon.png"167 }168 alt={collection.title}169 className="absolute inset-0 w-full h-full object-cover"170 onError={(e) => {171 (e.target as HTMLImageElement).src = "/favicon.png";172 }}173 />174 </div>175 </div>176 177 {/* Collection info */}178 <div className="flex flex-col justify-end gap-3 min-w-0">179 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">180 {collection.genre ? `${collection.genre} • ` : ""}181 {collection.type === "PLAYLIST" ? "Playlist" : "Album"}182 </span>183 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">184 {collection.title}185 </h1>186 187 <p className="text-xl text-gray-300 font-semibold">188 {collection.releasedBy}189 </p>190 191 {collection.songs && (192 <p className="text-sm text-gray-500">193 {collection.songs.length} song194 {collection.songs.length !== 1 ? "s" : ""}195 </p>196 )}197 198 {/* Action buttons */}199 <div className="flex items-center gap-3 mt-4">200 {type === "album" && (201 <button202 onClick={toggleCollectionLike}203 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${204 collection.isLikedByCurrentUser205 ? "bg-[#1db954] text-black"206 : "bg-white/10 text-white hover:bg-white/20"207 }`}208 >209 <svg210 className="w-5 h-5"211 fill={212 collection.isLikedByCurrentUser ? "currentColor" : "none"213 }214 stroke="currentColor"215 viewBox="0 0 24 24"216 >217 <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" />218 </svg>219 {collection.isLikedByCurrentUser ? "Liked" : "Like"}220 </button>221 )}222 </div>223 </div>224 </div>225 226 {/* Songs list */}227 <div className="border-t border-white/10 pt-6">228 <h2 className="text-2xl font-bold mb-4">Songs</h2>229 230 {collection.songs && collection.songs.length > 0 ? (231 <div className="space-y-1">232 {collection.songs.map((song, index) => (233 <SongItem234 key={song.id}235 song={song}236 index={index + 1}237 onLikeToggle={() => toggleLike(song.id)}238 />239 ))}240 </div>241 ) : (242 <div className="text-center py-12 text-gray-400">243 <p className="text-lg">No songs available</p>244 </div>245 )}246 </div>247 </div>248 </div>249 );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(getErrorMessage(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={ 164 collection.cover 165 ? `${baseURL}/${collection.cover}` 166 : "/favicon.png" 167 } 168 alt={collection.title} 169 className="absolute inset-0 w-full h-full object-cover" 170 onError={(e) => { 171 (e.target as HTMLImageElement).src = "/favicon.png"; 172 }} 173 /> 174 </div> 175 </div> 176 177 {/* Collection info */} 178 <div className="flex flex-col justify-end gap-3 min-w-0"> 179 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 180 {collection.genre ? `${collection.genre} • ` : ""} 181 {collection.type === "PLAYLIST" ? "Playlist" : "Album"} 182 </span> 183 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 184 {collection.title} 185 </h1> 186 187 <p className="text-xl text-gray-300 font-semibold"> 188 {collection.releasedBy} 189 </p> 190 191 {collection.songs && ( 192 <p className="text-sm text-gray-500"> 193 {collection.songs.length} song 194 {collection.songs.length !== 1 ? "s" : ""} 195 </p> 196 )} 197 198 {/* Action buttons */} 199 <div className="flex items-center gap-3 mt-4"> 200 {type === "album" && ( 201 <button 202 onClick={toggleCollectionLike} 203 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 204 collection.isLikedByCurrentUser 205 ? "bg-[#1db954] text-black" 206 : "bg-white/10 text-white hover:bg-white/20" 207 }`} 208 > 209 <svg 210 className="w-5 h-5" 211 fill={ 212 collection.isLikedByCurrentUser ? "currentColor" : "none" 213 } 214 stroke="currentColor" 215 viewBox="0 0 24 24" 216 > 217 <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" /> 218 </svg> 219 {collection.isLikedByCurrentUser ? "Liked" : "Like"} 220 </button> 221 )} 222 </div> 223 </div> 224 </div> 225 226 {/* Songs list */} 227 <div className="border-t border-white/10 pt-6"> 228 <h2 className="text-2xl font-bold mb-4">Songs</h2> 229 230 {collection.songs && collection.songs.length > 0 ? ( 231 <div className="space-y-1"> 232 {collection.songs.map((song, index) => ( 233 <SongItem 234 key={song.id} 235 song={song} 236 index={index + 1} 237 onLikeToggle={() => toggleLike(song.id)} 238 /> 239 ))} 240 </div> 241 ) : ( 242 <div className="text-center py-12 text-gray-400"> 243 <p className="text-lg">No songs available</p> 244 </div> 245 )} 246 </div> 247 </div> 248 </div> 249 ); 250 250 }; 251 251 -
frontend/src/pages/UserDetail.tsx
rf5bc95e r85512ff 7 7 import UserListModal from "../components/userProfile/UserListModal"; 8 8 import { useAuth } from "../context/authContext"; 9 import { handleError} from "../utils/error";9 import { getErrorMessage } from "../utils/error"; 10 10 import type { 11 ArtistContribution,12 BaseNonAdminUser,13 MusicalEntity,14 Playlist,11 ArtistContribution, 12 BaseNonAdminUser, 13 MusicalEntity, 14 Playlist, 15 15 } from "../utils/types"; 16 import { useCreatedPlaylists } from "../context/playlistContext"; 16 17 17 18 interface FollowStatus { 18 isFollowing: boolean;19 followerCount: number;20 followingCount: number;19 isFollowing: boolean; 20 followerCount: number; 21 followingCount: number; 21 22 } 22 23 23 24 interface Artist extends BaseNonAdminUser { 24 userType: "ARTIST";25 contributions: ArtistContribution[];25 userType: "ARTIST"; 26 contributions: ArtistContribution[]; 26 27 } 27 28 interface Listener extends BaseNonAdminUser { 28 userType: "LISTENER";29 likedEntities: MusicalEntity[];30 createdPlaylists: Playlist[];31 savedPlaylists: Playlist[];29 userType: "LISTENER"; 30 likedEntities: MusicalEntity[]; 31 createdPlaylists: Playlist[]; 32 savedPlaylists: Playlist[]; 32 33 } 33 34 … … 35 36 36 37 const UserDetail = () => { 37 const { username: usernameParam } = useParams(); 38 const { user: currentUser } = useAuth(); 39 const navigate = useNavigate(); 40 const [user, setUser] = useState<UserProfile | null>(null); 41 const [error, setError] = useState<string | null>(null); 42 const [showModal, setShowModal] = useState(false); 43 const [modalTitle, setModalTitle] = useState(""); 44 const [modalUsers, setModalUsers] = useState<any[]>([]); 45 const [isLoadingModal, setIsLoadingModal] = useState(false); 46 const [isFollowing, setIsFollowing] = useState(false); 47 48 const username = usernameParam || currentUser?.username; 49 const isOwnProfile = currentUser?.username === username; 50 51 if (!usernameParam && !currentUser) { 52 return ( 53 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 54 <div className="text-center"> 55 <p className="text-red-400 text-xl mb-4"> 56 You must be logged in to view your profile. 57 </p> 58 <button 59 onClick={() => navigate("/login")} 60 className="text-[#1db954] hover:underline text-sm cursor-pointer" 61 > 62 Go to Login 63 </button> 64 </div> 65 </div> 66 ); 67 } 68 69 const handleFollow = async () => { 70 if (!user) return; 71 72 setIsFollowing(true); 73 try { 74 const response = await axiosInstance.post<FollowStatus>( 75 `/users/na/${username}/follow`, 76 ); 77 setUser((prev) => { 78 if (!prev) return null; 79 return { 80 ...prev, 81 isFollowedByCurrentUser: response.data.isFollowing, 82 followers: response.data.followerCount, 83 following: response.data.followingCount, 84 }; 85 }); 86 } catch (err: any) { 87 setError(handleError(err)); 88 } finally { 89 setIsFollowing(false); 90 } 91 }; 92 93 const handleFollowInModal = async (targetUsername: string) => { 94 try { 95 const response = await axiosInstance.post<FollowStatus>( 96 `/users/na/${targetUsername}/follow`, 97 ); 98 99 setModalUsers((prevUsers) => 100 prevUsers.map((u) => 101 u.username === targetUsername 102 ? { ...u, isFollowedByCurrentUser: response.data.isFollowing } 103 : u, 104 ), 105 ); 106 } catch (err: any) { 107 setError(handleError(err)); 108 } 109 }; 110 111 const displayFollowers = async () => { 112 setIsLoadingModal(true); 113 try { 114 const response = await axiosInstance.get( 115 `/users/na/${username}/followers`, 116 ); 117 setModalUsers(response.data); 118 setModalTitle("Followers"); 119 setShowModal(true); 120 } catch (err) { 121 setError(handleError(err)); 122 } finally { 123 setIsLoadingModal(false); 124 } 125 }; 126 const displayFollowing = async () => { 127 setIsLoadingModal(true); 128 try { 129 const response = await axiosInstance.get( 130 `/users/na/${username}/following`, 131 ); 132 setModalUsers(response.data); 133 setModalTitle("Following"); 134 setShowModal(true); 135 } catch (err: any) { 136 setError(handleError(err)); 137 } finally { 138 setIsLoadingModal(false); 139 } 140 }; 141 142 useEffect(() => { 143 const fetchUser = async () => { 144 setError(null); 145 try { 146 const response = await axiosInstance.get(`/users/na/${username}`); 147 setUser(response.data); 148 } catch (err: any) { 149 setError(handleError(err)); 150 } 151 }; 152 fetchUser(); 153 }, [username]); 154 155 if (error) { 156 return ( 157 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 158 <div className="text-center"> 159 <p className="text-red-400 text-xl mb-4">{error}</p> 160 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 161 ← Back to Home 162 </Link> 163 </div> 164 </div> 165 ); 166 } 167 168 if (!user) { 169 return <LoadingSpinner />; 170 } 171 172 return ( 173 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 174 {isLoadingModal && ( 175 <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center"> 176 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 177 </div> 178 )} 179 180 <div className="max-w-5xl mx-auto px-6 py-10"> 181 {/* Back link */} 182 <Link 183 to="/" 184 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 185 > 186 <svg 187 className="w-4 h-4" 188 fill="none" 189 stroke="currentColor" 190 viewBox="0 0 24 24" 191 > 192 <path 193 strokeLinecap="round" 194 strokeLinejoin="round" 195 strokeWidth={2} 196 d="M15 19l-7-7 7-7" 197 /> 198 </svg> 199 Back to Home 200 </Link> 201 202 {/* Hero section */} 203 <div className="flex flex-col md:flex-row gap-8 mb-10"> 204 {/* Profile photo */} 205 <div className="w-full md:w-48 shrink-0"> 206 <div className="relative w-48 h-48 rounded-full overflow-hidden shadow-2xl bg-[#181818] mx-auto md:mx-0"> 207 {user.profilePhoto ? ( 208 <img 209 src={`${baseURL}/${user.profilePhoto}`} 210 alt={user.fullName} 211 className="w-full h-full object-cover" 212 /> 213 ) : ( 214 <div className="w-full h-full bg-linear-to-br from-[#1db954] to-[#1ed760] flex items-center justify-center text-white text-5xl font-bold"> 215 {user.fullName.charAt(0).toUpperCase()} 216 </div> 217 )} 218 </div> 219 </div> 220 221 {/* User info */} 222 <div className="flex flex-col justify-end gap-3 min-w-0"> 223 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 224 {user.userType === "ARTIST" ? "Artist" : "Listener"} • Profile 225 </span> 226 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight"> 227 {user.fullName} 228 </h1> 229 <p className="text-gray-400">@{user.username}</p> 230 231 {/* Stats */} 232 <div className="flex items-center gap-6 mt-2"> 233 <div 234 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 235 onClick={ 236 user.userType === "LISTENER" ? displayFollowers : undefined 237 } 238 > 239 <span className="text-xl font-bold text-white"> 240 {user.followers} 241 </span> 242 <span className="text-sm text-gray-400 ml-1">Followers</span> 243 </div> 244 <div 245 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 246 onClick={ 247 user.userType === "LISTENER" ? displayFollowing : undefined 248 } 249 > 250 <span className="text-xl font-bold text-white"> 251 {user.following} 252 </span> 253 <span className="text-sm text-gray-400 ml-1">Following</span> 254 </div> 255 </div> 256 257 {/* Follow button - hidden on own profile */} 258 {!isOwnProfile && ( 259 <div className="mt-4"> 260 <button 261 onClick={handleFollow} 262 disabled={isFollowing} 263 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 264 isFollowing 265 ? "bg-gray-700 text-gray-400 cursor-not-allowed" 266 : user.isFollowedByCurrentUser 267 ? "bg-white/10 text-white hover:bg-white/20" 268 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 269 }`} 270 > 271 {user.isFollowedByCurrentUser ? ( 272 <> 273 <svg 274 className="w-5 h-5" 275 fill="none" 276 stroke="currentColor" 277 viewBox="0 0 24 24" 278 > 279 <path 280 strokeLinecap="round" 281 strokeLinejoin="round" 282 strokeWidth={2} 283 d="M5 13l4 4L19 7" 284 /> 285 </svg> 286 Following 287 </> 288 ) : ( 289 <> 290 <svg 291 className="w-5 h-5" 292 fill="none" 293 stroke="currentColor" 294 viewBox="0 0 24 24" 295 > 296 <path 297 strokeLinecap="round" 298 strokeLinejoin="round" 299 strokeWidth={2} 300 d="M12 4v16m8-8H4" 301 /> 302 </svg> 303 Follow 304 </> 305 )} 306 </button> 307 </div> 308 )} 309 </div> 310 </div> 311 312 {/* Content */} 313 {user.userType === "ARTIST" ? ( 314 <ArtistView contributions={user.contributions} /> 315 ) : ( 316 <ListenerView 317 likedEntities={user.likedEntities} 318 createdPlaylists={user.createdPlaylists} 319 savedPlaylists={user.savedPlaylists} 320 /> 321 )} 322 323 {showModal && ( 324 <UserListModal 325 title={modalTitle} 326 users={modalUsers} 327 onClose={() => setShowModal(false)} 328 onFollowToggle={handleFollowInModal} 329 /> 330 )} 331 </div> 332 </div> 333 ); 38 const { username: usernameParam } = useParams(); 39 const { user: currentUser } = useAuth(); 40 const { createdPlaylists: currentUserCreatedPlaylists } = 41 useCreatedPlaylists(); 42 const navigate = useNavigate(); 43 const [user, setUser] = useState<UserProfile | null>(null); 44 const [error, setError] = useState<string | null>(null); 45 const [showModal, setShowModal] = useState(false); 46 const [modalTitle, setModalTitle] = useState(""); 47 const [modalUsers, setModalUsers] = useState<any[]>([]); 48 const [isLoadingModal, setIsLoadingModal] = useState(false); 49 const [isFollowing, setIsFollowing] = useState(false); 50 51 const username = usernameParam || currentUser?.username; 52 const isOwnProfile = currentUser?.username === username; 53 54 if (!usernameParam && !currentUser) { 55 return ( 56 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 57 <div className="text-center"> 58 <p className="text-red-400 text-xl mb-4"> 59 You must be logged in to view your profile. 60 </p> 61 <button 62 onClick={() => navigate("/login")} 63 className="text-[#1db954] hover:underline text-sm cursor-pointer" 64 > 65 Go to Login 66 </button> 67 </div> 68 </div> 69 ); 70 } 71 72 const handleFollow = async () => { 73 if (!user) return; 74 75 setIsFollowing(true); 76 try { 77 const response = await axiosInstance.post<FollowStatus>( 78 `/users/na/${username}/follow`, 79 ); 80 setUser((prev) => { 81 if (!prev) return null; 82 return { 83 ...prev, 84 isFollowedByCurrentUser: response.data.isFollowing, 85 followers: response.data.followerCount, 86 following: response.data.followingCount, 87 }; 88 }); 89 } catch (err: any) { 90 setError(getErrorMessage(err)); 91 } finally { 92 setIsFollowing(false); 93 } 94 }; 95 96 const handleFollowInModal = async (targetUsername: string) => { 97 try { 98 const response = await axiosInstance.post<FollowStatus>( 99 `/users/na/${targetUsername}/follow`, 100 ); 101 102 setModalUsers((prevUsers) => 103 prevUsers.map((u) => 104 u.username === targetUsername 105 ? { ...u, isFollowedByCurrentUser: response.data.isFollowing } 106 : u, 107 ), 108 ); 109 } catch (err: any) { 110 setError(getErrorMessage(err)); 111 } 112 }; 113 114 const displayFollowers = async () => { 115 setIsLoadingModal(true); 116 try { 117 const response = await axiosInstance.get( 118 `/users/na/${username}/followers`, 119 ); 120 setModalUsers(response.data); 121 setModalTitle("Followers"); 122 setShowModal(true); 123 } catch (err) { 124 setError(getErrorMessage(err)); 125 } finally { 126 setIsLoadingModal(false); 127 } 128 }; 129 const displayFollowing = async () => { 130 setIsLoadingModal(true); 131 try { 132 const response = await axiosInstance.get( 133 `/users/na/${username}/following`, 134 ); 135 setModalUsers(response.data); 136 setModalTitle("Following"); 137 setShowModal(true); 138 } catch (err: any) { 139 setError(getErrorMessage(err)); 140 } finally { 141 setIsLoadingModal(false); 142 } 143 }; 144 145 useEffect(() => { 146 const fetchUser = async () => { 147 setError(null); 148 setUser(null); 149 try { 150 const response = await axiosInstance.get(`/users/na/${username}`); 151 152 setUser(response.data); 153 } catch (err: any) { 154 setError(getErrorMessage(err)); 155 } 156 }; 157 fetchUser(); 158 }, [username]); 159 160 if (error) { 161 return ( 162 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 163 <div className="text-center"> 164 <p className="text-red-400 text-xl mb-4">{error}</p> 165 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 166 ← Back to Home 167 </Link> 168 </div> 169 </div> 170 ); 171 } 172 173 if (!user) { 174 return <LoadingSpinner />; 175 } 176 177 return ( 178 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 179 {isLoadingModal && ( 180 <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center"> 181 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 182 </div> 183 )} 184 185 <div className="max-w-5xl mx-auto px-6 py-10"> 186 {/* Back link */} 187 <Link 188 to="/" 189 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 190 > 191 <svg 192 className="w-4 h-4" 193 fill="none" 194 stroke="currentColor" 195 viewBox="0 0 24 24" 196 > 197 <path 198 strokeLinecap="round" 199 strokeLinejoin="round" 200 strokeWidth={2} 201 d="M15 19l-7-7 7-7" 202 /> 203 </svg> 204 Back to Home 205 </Link> 206 207 {/* Hero section */} 208 <div className="flex flex-col md:flex-row gap-8 mb-10"> 209 {/* Profile photo */} 210 <div className="w-full md:w-48 shrink-0"> 211 <div className="relative w-48 h-48 rounded-full overflow-hidden shadow-2xl bg-[#181818] mx-auto md:mx-0"> 212 {user.profilePhoto ? ( 213 <img 214 src={`${baseURL}/${user.profilePhoto}`} 215 alt={user.fullName} 216 className="w-full h-full object-cover" 217 /> 218 ) : ( 219 <div className="w-full h-full bg-linear-to-br from-[#1db954] to-[#1ed760] flex items-center justify-center text-white text-5xl font-bold"> 220 {user.fullName.charAt(0).toUpperCase()} 221 </div> 222 )} 223 </div> 224 </div> 225 226 {/* User info */} 227 <div className="flex flex-col justify-end gap-3 min-w-0"> 228 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 229 {user.userType === "ARTIST" ? "Artist" : "Listener"} • Profile 230 </span> 231 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight"> 232 {user.fullName} 233 </h1> 234 <p className="text-gray-400">@{user.username}</p> 235 236 {/* Stats */} 237 <div className="flex items-center gap-6 mt-2"> 238 <div 239 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 240 onClick={ 241 user.userType === "LISTENER" ? displayFollowers : undefined 242 } 243 > 244 <span className="text-xl font-bold text-white"> 245 {user.followers} 246 </span> 247 <span className="text-sm text-gray-400 ml-1">Followers</span> 248 </div> 249 <div 250 className={`${user.userType === "LISTENER" ? "cursor-pointer hover:text-white" : ""} transition-colors`} 251 onClick={ 252 user.userType === "LISTENER" ? displayFollowing : undefined 253 } 254 > 255 <span className="text-xl font-bold text-white"> 256 {user.following} 257 </span> 258 <span className="text-sm text-gray-400 ml-1">Following</span> 259 </div> 260 </div> 261 262 {/* Follow button - hidden on own profile */} 263 {!isOwnProfile && ( 264 <div className="mt-4"> 265 <button 266 onClick={handleFollow} 267 disabled={isFollowing} 268 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 269 isFollowing 270 ? "bg-gray-700 text-gray-400 cursor-not-allowed" 271 : user.isFollowedByCurrentUser 272 ? "bg-white/10 text-white hover:bg-white/20" 273 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 274 }`} 275 > 276 {user.isFollowedByCurrentUser ? ( 277 <> 278 <svg 279 className="w-5 h-5" 280 fill="none" 281 stroke="currentColor" 282 viewBox="0 0 24 24" 283 > 284 <path 285 strokeLinecap="round" 286 strokeLinejoin="round" 287 strokeWidth={2} 288 d="M5 13l4 4L19 7" 289 /> 290 </svg> 291 Following 292 </> 293 ) : ( 294 <> 295 <svg 296 className="w-5 h-5" 297 fill="none" 298 stroke="currentColor" 299 viewBox="0 0 24 24" 300 > 301 <path 302 strokeLinecap="round" 303 strokeLinejoin="round" 304 strokeWidth={2} 305 d="M12 4v16m8-8H4" 306 /> 307 </svg> 308 Follow 309 </> 310 )} 311 </button> 312 </div> 313 )} 314 </div> 315 </div> 316 317 {/* Content */} 318 {user.userType === "ARTIST" ? ( 319 <ArtistView contributions={user.contributions} /> 320 ) : ( 321 <ListenerView 322 likedEntities={user.likedEntities} 323 createdPlaylists={user.createdPlaylists} 324 savedPlaylists={user.savedPlaylists} 325 /> 326 )} 327 328 {showModal && ( 329 <UserListModal 330 title={modalTitle} 331 users={modalUsers} 332 onClose={() => setShowModal(false)} 333 onFollowToggle={handleFollowInModal} 334 /> 335 )} 336 </div> 337 </div> 338 ); 334 339 }; 335 340 -
frontend/src/utils/error.ts
rf5bc95e r85512ff 1 export const handleError= (err: any) => {1 export const getErrorMessage = (err: any) => { 2 2 const errorMessage = err.response?.data?.error || "Failed to fetch user"; 3 3 return errorMessage; -
frontend/src/utils/types.ts
rf5bc95e r85512ff 1 1 export interface User { 2 username: string;3 fullName: string;4 email?: string;5 profilePhoto?: string | null;6 isAdmin: boolean;7 isArtist: boolean;2 username: string; 3 fullName: string; 4 email?: string; 5 profilePhoto?: string | null; 6 isAdmin: boolean; 7 isArtist: boolean; 8 8 } 9 9 10 10 export interface ArtistContribution { 11 id: number;12 title: string;13 genre: string;14 role: string;15 entityType: string;16 isLikedByCurrentUser: boolean;17 cover?: string | null;18 link?: string | null;11 id: number; 12 title: string; 13 genre: string; 14 role: string; 15 entityType: string; 16 isLikedByCurrentUser: boolean; 17 cover?: string | null; 18 link?: string | null; 19 19 } 20 20 21 21 export interface MusicalEntity { 22 id: number;23 title: string;24 genre: string;25 type: string;26 releasedBy: string;27 artistUsername?: string;28 cover?: string | null;29 isLikedByCurrentUser?: boolean;22 id: number; 23 title: string; 24 genre: string; 25 type: string; 26 releasedBy: string; 27 artistUsername?: string; 28 cover?: string | null; 29 isLikedByCurrentUser?: boolean; 30 30 } 31 31 32 32 export interface Song extends MusicalEntity { 33 type: "SONG";34 album?: string;35 albumId?: number;36 link?: string;33 type: "SONG"; 34 album?: string; 35 albumId?: number; 36 link?: string; 37 37 } 38 38 39 39 export interface Album extends MusicalEntity { 40 type: "ALBUM";41 songs: Song[];40 type: "ALBUM"; 41 songs: Song[]; 42 42 } 43 43 44 44 export interface Playlist { 45 id: number; 46 name: string; 47 cover: string; 48 creatorName: string; 49 songsInPlaylist: Song[]; 50 isSavedByCurrentUser: boolean; 45 id: number; 46 name: string; 47 cover: string; 48 creatorName: string; 49 creatorUsername: string; 50 songsInPlaylist: Song[]; 51 isSavedByCurrentUser: boolean; 51 52 } 52 53 53 54 export interface BaseNonAdminUser { 54 username: string;55 fullName: string;56 userType: string;57 profilePhoto: string;58 followers: number;59 following: number;60 isFollowedByCurrentUser: boolean;55 username: string; 56 fullName: string; 57 userType: string; 58 profilePhoto: string; 59 followers: number; 60 following: number; 61 isFollowedByCurrentUser: boolean; 61 62 } 62 63 … … 66 67 67 68 export interface SidebarProps { 68 isOpen: boolean; 69 onClose: () => void; 69 isOpen: boolean; 70 onClose: () => void; 71 } 72 73 export interface CreatePlaylistModalProps { 74 isOpen: boolean; 75 onClose: () => void; 76 onSubmit: (playlistName: string) => void; 70 77 } 71 78 72 79 export interface SongContribution { 73 artistName: string;74 role: string;80 artistName: string; 81 role: string; 75 82 } 76 83 77 84 export interface SongReview { 78 id: {79 listenerId: number;80 musicalEntityId: number;81 };82 author: string;83 authorUsername: string;84 grade: number;85 comment: string;85 id: { 86 listenerId: number; 87 musicalEntityId: number; 88 }; 89 author: string; 90 authorUsername: string; 91 grade: number; 92 comment: string; 86 93 } 87 94 88 95 export interface SongDetail extends MusicalEntity { 89 type: "SONG";90 album?: string | null;91 link?: string | null;92 contributions: SongContribution[];93 reviews: SongReview[];96 type: "SONG"; 97 album?: string | null; 98 link?: string | null; 99 contributions: SongContribution[]; 100 reviews: SongReview[]; 94 101 } 95 102 96 103 export interface BasicSong { 97 id: number;98 title: string;99 artist: string;100 artistUsername?: string;101 cover?: string;102 link?: string;103 album?: string;104 albumId?: number;104 id: number; 105 title: string; 106 artist: string; 107 artistUsername?: string; 108 cover?: string; 109 link?: string; 110 album?: string; 111 albumId?: number; 105 112 } 106 113 107 114 export interface BasicPlaylist { 108 id: number;109 name: string;110 songCount: number;115 id: number; 116 name: string; 117 songCount: number; 111 118 } 112 119 113 120 export interface CatalogItem { 114 id: number;115 title: string;116 genre: string;117 cover: string | null;118 type: "SONG" | "ALBUM";119 releaseDate: string;121 id: number; 122 title: string; 123 genre: string; 124 cover: string | null; 125 type: "SONG" | "ALBUM"; 126 releaseDate: string; 120 127 } 121 128 122 129 export interface Contributor { 123 id: number;124 fullName: string;125 role: string;130 id: number; 131 fullName: string; 132 role: string; 126 133 } 127 134 128 135 export interface ArtistSearchResult { 129 id: number;130 username: string;131 fullName: string;132 profilePhoto?: string;136 id: number; 137 username: string; 138 fullName: string; 139 profilePhoto?: string; 133 140 } 134 141 135 142 export interface SongEntry { 136 title: string;137 link: string;138 contributors: Contributor[];143 title: string; 144 link: string; 145 contributors: Contributor[]; 139 146 }
Note:
See TracChangeset
for help on using the changeset viewer.
