Changeset ce45c7a for frontend/src/components
- Timestamp:
- 02/15/26 00:39:55 (5 months ago)
- Branches:
- main
- Children:
- c8baad1
- Parents:
- 85512ff
- Location:
- frontend/src/components
- Files:
-
- 1 added
- 4 edited
-
PlaylistDropdown.tsx (added)
-
Sidebar.tsx (modified) (1 diff)
-
SongItem.tsx (modified) (1 diff)
-
userProfile/ArtistView.tsx (modified) (1 diff)
-
userProfile/ListenerView.tsx (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/components/Sidebar.tsx
r85512ff rce45c7a 48 48 await axiosInstance.post("/playlists", { name: playlistName }); 49 49 toast.success("Playlist created successfully!"); 50 await refreshPlaylists( );50 await refreshPlaylists(false); 51 51 } catch (error) { 52 52 toast.error(getErrorMessage(error)); -
frontend/src/components/SongItem.tsx
r85512ff rce45c7a 1 import { use Effect, useRef, useState } from "react";1 import { useRef, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 3 import { baseURL } from "../api/axiosInstance"; 4 4 import { usePlayer } from "../context/playerContext"; 5 5 import { toEmbedUrl } from "../utils/utils"; 6 import PlaylistDropdown from "./PlaylistDropdown"; 6 7 7 8 export interface SongItemData { 8 id: number;9 title: string;10 cover?: string | null;11 genre?: string;12 link?: string | null;13 releasedBy?: string;14 isLikedByCurrentUser?: boolean;9 id: number; 10 title: string; 11 cover?: string | null; 12 genre?: string; 13 link?: string | null; 14 releasedBy?: string; 15 isLikedByCurrentUser?: boolean; 15 16 } 16 17 17 18 interface SongItemProps { 18 song: SongItemData; 19 /** Optional label shown before the artist, e.g. "Song" for search results */ 20 label?: string; 21 /** Optional role badge for artist contributions, e.g. "PERFORMER" */ 22 role?: string; 23 /** Optional index number for playlist/collection views */ 24 index?: number; 25 /** Callback when the like button is clicked */ 26 onLikeToggle?: (songId: number) => void; 19 song: SongItemData; 20 /** Optional label shown before the artist, e.g. "Song" for search results */ 21 label?: string; 22 /** Optional role badge for artist contributions, e.g. "PERFORMER" */ 23 role?: string; 24 /** Optional index number for playlist/collection views */ 25 index?: number; 26 /** Callback when the like button is clicked */ 27 onLikeToggle?: (songId: number) => void; 28 /** Controlled: whether the playlist dropdown is open */ 29 isDropdownOpen?: boolean; 30 /** Controlled: callback when dropdown should open/close */ 31 onDropdownToggle?: (songId: number | null) => void; 27 32 } 28 33 29 34 const ROLE_COLORS: { [key: string]: string } = { 30 COMPOSER: "bg-purple-500/20 text-purple-300",31 PERFORMER: "bg-blue-500/20 text-blue-300",32 PRODUCER: "bg-green-500/20 text-green-300",33 MAIN_VOCAL: "bg-pink-500/20 text-pink-300",35 COMPOSER: "bg-purple-500/20 text-purple-300", 36 PERFORMER: "bg-blue-500/20 text-blue-300", 37 PRODUCER: "bg-green-500/20 text-green-300", 38 MAIN_VOCAL: "bg-pink-500/20 text-pink-300", 34 39 }; 35 40 36 41 const SongItem = ({ 37 song, 38 label, 39 role, 40 index, 41 onLikeToggle, 42 song, 43 label, 44 role, 45 index, 46 onLikeToggle, 47 isDropdownOpen, 48 onDropdownToggle, 42 49 }: SongItemProps) => { 43 const navigate = useNavigate(); 44 const { play, currentSong } = usePlayer(); 45 const [playlistOpen, setPlaylistOpen] = useState(false); 46 const dropdownRef = useRef<HTMLDivElement>(null); 47 48 const isPlaying = currentSong?.id === song.id; 49 50 useEffect(() => { 51 const handleClickOutside = (event: MouseEvent) => { 52 if ( 53 dropdownRef.current && 54 !dropdownRef.current.contains(event.target as Node) 55 ) { 56 setPlaylistOpen(false); 57 } 58 }; 59 if (playlistOpen) { 60 document.addEventListener("mousedown", handleClickOutside); 61 } 62 return () => document.removeEventListener("mousedown", handleClickOutside); 63 }, [playlistOpen]); 64 65 const handleAddToPlaylist = (playlistName: string) => { 66 console.log(`Adding song ${song.id} to ${playlistName}`); 67 // TODO: Implement actual API call 68 setPlaylistOpen(false); 69 }; 70 71 const handleCreateNewPlaylist = () => { 72 console.log(`Creating new playlist for song ${song.id}`); 73 // TODO: Implement actual playlist creation 74 setPlaylistOpen(false); 75 }; 76 77 // Build subtitle 78 const subtitleParts: string[] = []; 79 if (label) subtitleParts.push(label); 80 if (song.releasedBy) subtitleParts.push(song.releasedBy); 81 const subtitle = subtitleParts.join(" • "); 82 83 return ( 84 <div 85 onClick={() => navigate(`/songs/${song.id}`)} 86 className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group" 87 > 88 {/* Optional index */} 89 {index != null && ( 90 <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0"> 91 {index} 92 </span> 93 )} 94 95 {/* Cover */} 96 <img 97 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 98 alt={song.title} 99 className="w-12 h-12 rounded object-cover shrink-0" 100 onError={(e) => { 101 (e.target as HTMLImageElement).src = "/favicon.png"; 102 }} 103 /> 104 105 {/* Title & subtitle */} 106 <div className="flex-1 min-w-0"> 107 <p 108 className={`font-medium truncate ${ 109 isPlaying ? "text-[#1db954]" : "text-white" 110 }`} 111 > 112 {song.title} 113 </p> 114 {subtitle && ( 115 <p className="text-sm text-gray-400 truncate">{subtitle}</p> 116 )} 117 </div> 118 119 {/* Role badge (artist contributions) */} 120 {role && ( 121 <span 122 className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${ 123 ROLE_COLORS[role] || "bg-white/10 text-gray-300" 124 }`} 125 > 126 {role.replace("_", " ")} 127 </span> 128 )} 129 130 {/* Genre */} 131 {song.genre && ( 132 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block"> 133 {song.genre} 134 </span> 135 )} 136 137 {/* Play button */} 138 {song.link && ( 139 <button 140 onClick={(e) => { 141 e.stopPropagation(); 142 play({ 143 id: song.id, 144 title: song.title, 145 artist: song.releasedBy || "", 146 cover: song.cover, 147 embedUrl: toEmbedUrl(song.link!), 148 }); 149 }} 150 className={`p-2 rounded-full transition-all cursor-pointer ${ 151 isPlaying 152 ? "bg-white text-[#1db954] opacity-100" 153 : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100" 154 }`} 155 aria-label={isPlaying ? "Now playing" : "Play song"} 156 > 157 {isPlaying ? ( 158 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 159 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 160 </svg> 161 ) : ( 162 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 163 <path d="M8 5v14l11-7z" /> 164 </svg> 165 )} 166 </button> 167 )} 168 169 {/* Like button */} 170 {onLikeToggle && ( 171 <button 172 onClick={(e) => { 173 e.stopPropagation(); 174 onLikeToggle(song.id); 175 }} 176 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer" 177 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"} 178 > 179 <svg 180 className="w-5 h-5" 181 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 182 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 183 strokeWidth="2" 184 viewBox="0 0 24 24" 185 > 186 <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" /> 187 </svg> 188 </button> 189 )} 190 191 {/* Three-dot menu */} 192 <div className="relative" ref={dropdownRef}> 193 <button 194 onClick={(e) => { 195 e.stopPropagation(); 196 setPlaylistOpen((prev) => !prev); 197 }} 198 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white" 199 aria-label="More options" 200 > 201 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 202 <circle cx="12" cy="5" r="1.5" /> 203 <circle cx="12" cy="12" r="1.5" /> 204 <circle cx="12" cy="19" r="1.5" /> 205 </svg> 206 </button> 207 208 {playlistOpen && ( 209 <div className="absolute right-0 bottom-full mb-2 w-48 bg-[#282828] border border-white/10 rounded-lg shadow-lg py-1 z-50"> 210 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10"> 211 Add to playlist 212 </div> 213 <button 214 onClick={(e) => { 215 e.stopPropagation(); 216 handleAddToPlaylist("Playlist 1"); 217 }} 218 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors" 219 > 220 Playlist 1 221 </button> 222 <button 223 onClick={(e) => { 224 e.stopPropagation(); 225 handleAddToPlaylist("Playlist 2"); 226 }} 227 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors" 228 > 229 Playlist 2 230 </button> 231 <button 232 onClick={(e) => { 233 e.stopPropagation(); 234 handleCreateNewPlaylist(); 235 }} 236 className="w-full text-left px-4 py-2 text-sm text-[#1db954] hover:bg-white/10 transition-colors border-t border-white/10 flex items-center gap-2" 237 > 238 <svg 239 className="w-4 h-4" 240 fill="none" 241 stroke="currentColor" 242 viewBox="0 0 24 24" 243 > 244 <path 245 strokeLinecap="round" 246 strokeLinejoin="round" 247 strokeWidth={2} 248 d="M12 4v16m8-8H4" 249 /> 250 </svg> 251 Create new playlist 252 </button> 253 </div> 254 )} 255 </div> 256 </div> 257 ); 50 const navigate = useNavigate(); 51 const { play, currentSong } = usePlayer(); 52 const [internalOpen, setInternalOpen] = useState(false); 53 const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); 54 const [dropdownDirection, setDropdownDirection] = useState<"above" | "below">( 55 "below", 56 ); 57 const buttonRef = useRef<HTMLButtonElement>(null); 58 59 const playlistOpen = isDropdownOpen ?? internalOpen; 60 const setPlaylistOpen = (open: boolean) => { 61 if (onDropdownToggle) { 62 onDropdownToggle(open ? song.id : null); 63 } else { 64 setInternalOpen(open); 65 } 66 }; 67 68 const isPlaying = currentSong?.id === song.id; 69 70 const handleCreateNewPlaylist = () => { 71 console.log(`Creating new playlist for song ${song.id}`); 72 // TODO: Implement actual playlist creation 73 setPlaylistOpen(false); 74 }; 75 76 const subtitleParts: string[] = []; 77 if (label) subtitleParts.push(label); 78 if (song.releasedBy) subtitleParts.push(song.releasedBy); 79 const subtitle = subtitleParts.join(" • "); 80 81 return ( 82 <div 83 onClick={() => navigate(`/songs/${song.id}`)} 84 onMouseEnter={() => { 85 if (onDropdownToggle && !playlistOpen) { 86 onDropdownToggle(null); 87 } 88 }} 89 className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group" 90 > 91 {/* Optional index */} 92 {index != null && ( 93 <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0"> 94 {index} 95 </span> 96 )} 97 98 {/* Cover */} 99 <img 100 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 101 alt={song.title} 102 className="w-12 h-12 rounded object-cover shrink-0" 103 onError={(e) => { 104 (e.target as HTMLImageElement).src = "/favicon.png"; 105 }} 106 /> 107 108 {/* Title & subtitle */} 109 <div className="flex-1 min-w-0"> 110 <p 111 className={`font-medium truncate ${ 112 isPlaying ? "text-[#1db954]" : "text-white" 113 }`} 114 > 115 {song.title} 116 </p> 117 {subtitle && ( 118 <p className="text-sm text-gray-400 truncate">{subtitle}</p> 119 )} 120 </div> 121 122 {/* Role badge (artist contributions) */} 123 {role && ( 124 <span 125 className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${ 126 ROLE_COLORS[role] || "bg-white/10 text-gray-300" 127 }`} 128 > 129 {role.replace("_", " ")} 130 </span> 131 )} 132 133 {/* Genre */} 134 {song.genre && ( 135 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block"> 136 {song.genre} 137 </span> 138 )} 139 140 {/* Play button */} 141 {song.link && ( 142 <button 143 onClick={(e) => { 144 e.stopPropagation(); 145 play({ 146 id: song.id, 147 title: song.title, 148 artist: song.releasedBy || "", 149 cover: song.cover, 150 embedUrl: toEmbedUrl(song.link!), 151 }); 152 }} 153 className={`p-2 rounded-full transition-all cursor-pointer ${ 154 isPlaying 155 ? "bg-white text-[#1db954] opacity-100" 156 : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100" 157 }`} 158 aria-label={isPlaying ? "Now playing" : "Play song"} 159 > 160 {isPlaying ? ( 161 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 162 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 163 </svg> 164 ) : ( 165 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 166 <path d="M8 5v14l11-7z" /> 167 </svg> 168 )} 169 </button> 170 )} 171 172 {/* Like button */} 173 {onLikeToggle && ( 174 <button 175 onClick={(e) => { 176 e.stopPropagation(); 177 onLikeToggle(song.id); 178 }} 179 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer" 180 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"} 181 > 182 <svg 183 className="w-5 h-5" 184 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"} 185 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"} 186 strokeWidth="2" 187 viewBox="0 0 24 24" 188 > 189 <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" /> 190 </svg> 191 </button> 192 )} 193 194 {/* Three-dot menu */} 195 <div className="relative"> 196 <button 197 ref={buttonRef} 198 onClick={(e) => { 199 e.stopPropagation(); 200 if (playlistOpen) { 201 setPlaylistOpen(false); 202 } else { 203 if (buttonRef.current) { 204 const rect = buttonRef.current.getBoundingClientRect(); 205 const spaceBelow = window.innerHeight - rect.bottom; 206 const dropdownHeight = 260; 207 208 if (spaceBelow < dropdownHeight) { 209 setDropdownDirection("above"); 210 setDropdownPosition({ 211 top: rect.top - 8, 212 left: rect.right - 224, 213 }); 214 } else { 215 setDropdownDirection("below"); 216 setDropdownPosition({ 217 top: rect.bottom + 8, 218 left: rect.right - 224, 219 }); 220 } 221 } 222 setPlaylistOpen(true); 223 } 224 }} 225 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white" 226 aria-label="More options" 227 > 228 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> 229 <circle cx="12" cy="5" r="1.5" /> 230 <circle cx="12" cy="12" r="1.5" /> 231 <circle cx="12" cy="19" r="1.5" /> 232 </svg> 233 </button> 234 235 <PlaylistDropdown 236 songId={song.id} 237 isOpen={playlistOpen} 238 onClose={() => setPlaylistOpen(false)} 239 position={dropdownPosition} 240 usePortal={true} 241 direction={dropdownDirection} 242 onCreateNewPlaylist={handleCreateNewPlaylist} 243 /> 244 </div> 245 </div> 246 ); 258 247 }; 259 248 -
frontend/src/components/userProfile/ArtistView.tsx
r85512ff rce45c7a 8 8 9 9 interface ArtistViewProps { 10 contributions: ArtistContribution[];10 contributions: ArtistContribution[]; 11 11 } 12 12 13 13 const ArtistView = ({ contributions }: ArtistViewProps) => { 14 const navigate = useNavigate(); 15 const [items, setItems] = useState(contributions); 14 const navigate = useNavigate(); 15 const [items, setItems] = useState(contributions); 16 const [openDropdownSongId, setOpenDropdownSongId] = useState<number | null>( 17 null, 18 ); 16 19 17 const albums = items.filter((c) => c.entityType === "ALBUM");18 const songs = items.filter((c) => c.entityType === "SONG");20 const albums = items.filter((c) => c.entityType === "ALBUM"); 21 const songs = items.filter((c) => c.entityType === "SONG"); 19 22 20 const getRoleColor = (role: string) => {21 const colors: { [key: string]: string } = {22 COMPOSER: "bg-purple-500/20 text-purple-300",23 PERFORMER: "bg-blue-500/20 text-blue-300",24 PRODUCER: "bg-green-500/20 text-green-300",25 MAIN_VOCAL: "bg-pink-500/20 text-pink-300",26 };27 return colors[role] || "bg-white/10 text-gray-300";28 };23 const getRoleColor = (role: string) => { 24 const colors: { [key: string]: string } = { 25 COMPOSER: "bg-purple-500/20 text-purple-300", 26 PERFORMER: "bg-blue-500/20 text-blue-300", 27 PRODUCER: "bg-green-500/20 text-green-300", 28 MAIN_VOCAL: "bg-pink-500/20 text-pink-300", 29 }; 30 return colors[role] || "bg-white/10 text-gray-300"; 31 }; 29 32 30 const handleLike = async (id: number, title: string) => {31 try {32 const response = await axiosInstance.post(`/musical-entity/${id}/like`);33 const data = response.data;33 const handleLike = async (id: number, title: string) => { 34 try { 35 const response = await axiosInstance.post(`/musical-entity/${id}/like`); 36 const data = response.data; 34 37 35 setItems((prevItems) =>36 prevItems.map((item) =>37 item.id === data.entityId38 ? { ...item, isLikedByCurrentUser: data.isLiked }39 : item,40 ),41 );42 toast.success(43 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`,44 );45 } catch (err: any) {46 toast.error(err.response?.data?.error || "Failed to like the item");47 }48 };38 setItems((prevItems) => 39 prevItems.map((item) => 40 item.id === data.entityId 41 ? { ...item, isLikedByCurrentUser: data.isLiked } 42 : item, 43 ), 44 ); 45 toast.success( 46 data.isLiked ? `Liked "${title}"` : `Removed "${title}" from likes`, 47 ); 48 } catch (err: any) { 49 toast.error(err.response?.data?.error || "Failed to like the item"); 50 } 51 }; 49 52 50 return (51 <div className="mt-8">52 {albums.length > 0 && (53 <div className="mb-12">54 <div className="flex items-center gap-3 mb-6">55 <Disc3 className="w-6 h-6 text-[#1db954]" />56 <h2 className="text-2xl font-bold text-white">Albums</h2>57 </div>58 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">59 {albums.map((album) => (60 <div61 key={album.id}62 className="group cursor-pointer"63 onClick={() => navigate(`/collection/album/${album.id}`)}64 >65 <div className="relative aspect-square rounded-lg mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 bg-[#181818]">66 <img67 src={68 album.cover ? `${baseURL}/${album.cover}` : "/favicon.png"69 }70 alt={album.title}71 className="w-full h-full object-cover"72 onError={(e) => {73 (e.target as HTMLImageElement).src = "/favicon.png";74 }}75 />53 return ( 54 <div className="mt-8"> 55 {albums.length > 0 && ( 56 <div className="mb-12"> 57 <div className="flex items-center gap-3 mb-6"> 58 <Disc3 className="w-6 h-6 text-[#1db954]" /> 59 <h2 className="text-2xl font-bold text-white">Albums</h2> 60 </div> 61 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 62 {albums.map((album) => ( 63 <div 64 key={album.id} 65 className="group cursor-pointer" 66 onClick={() => navigate(`/collection/album/${album.id}`)} 67 > 68 <div className="relative aspect-square rounded-lg mb-3 overflow-hidden shadow-md group-hover:shadow-xl transition-all duration-300 bg-[#181818]"> 69 <img 70 src={ 71 album.cover ? `${baseURL}/${album.cover}` : "/favicon.png" 72 } 73 alt={album.title} 74 className="w-full h-full object-cover" 75 onError={(e) => { 76 (e.target as HTMLImageElement).src = "/favicon.png"; 77 }} 78 /> 76 79 77 <button78 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"79 title={album.isLikedByCurrentUser ? "Unlike" : "Like"}80 onClick={(e) => {81 e.stopPropagation();82 handleLike(album.id, album.title);83 }}84 >85 <svg86 className="w-5 h-5"87 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"}88 stroke={89 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af"90 }91 strokeWidth="2"92 viewBox="0 0 24 24"93 >94 <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" />95 </svg>96 </button>80 <button 81 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" 82 title={album.isLikedByCurrentUser ? "Unlike" : "Like"} 83 onClick={(e) => { 84 e.stopPropagation(); 85 handleLike(album.id, album.title); 86 }} 87 > 88 <svg 89 className="w-5 h-5" 90 fill={album.isLikedByCurrentUser ? "#ef4444" : "none"} 91 stroke={ 92 album.isLikedByCurrentUser ? "#ef4444" : "#9ca3af" 93 } 94 strokeWidth="2" 95 viewBox="0 0 24 24" 96 > 97 <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" /> 98 </svg> 99 </button> 97 100 98 <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-3">99 <span100 className={`text-xs px-2 py-1 rounded-full font-medium ${getRoleColor(album.role)}`}101 >102 {album.role.replace("_", " ")}103 </span>104 </div>105 </div>106 <h3 className="font-semibold text-sm line-clamp-2 text-white group-hover:text-[#1db954] transition-colors">107 {album.title}108 </h3>109 </div>110 ))}111 </div>112 </div>113 )}101 <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-3"> 102 <span 103 className={`text-xs px-2 py-1 rounded-full font-medium ${getRoleColor(album.role)}`} 104 > 105 {album.role.replace("_", " ")} 106 </span> 107 </div> 108 </div> 109 <h3 className="font-semibold text-sm line-clamp-2 text-white group-hover:text-[#1db954] transition-colors"> 110 {album.title} 111 </h3> 112 </div> 113 ))} 114 </div> 115 </div> 116 )} 114 117 115 {songs.length > 0 && ( 116 <div className="mb-12"> 117 <div className="flex items-center gap-3 mb-6"> 118 <Music className="w-6 h-6 text-[#1db954]" /> 119 <h2 className="text-2xl font-bold text-white">Songs</h2> 120 </div> 121 <div className="space-y-1"> 122 {songs.map((song) => ( 123 <SongItem 124 key={song.id} 125 song={{ 126 id: song.id, 127 title: song.title, 128 cover: song.cover, 129 genre: song.genre, 130 link: song.link, 131 isLikedByCurrentUser: song.isLikedByCurrentUser, 132 }} 133 role={song.role} 134 onLikeToggle={() => handleLike(song.id, song.title)} 135 /> 136 ))} 137 </div> 138 </div> 139 )} 118 {songs.length > 0 && ( 119 <div className="mb-12"> 120 <div className="flex items-center gap-3 mb-6"> 121 <Music className="w-6 h-6 text-[#1db954]" /> 122 <h2 className="text-2xl font-bold text-white">Songs</h2> 123 </div> 124 <div className="space-y-1"> 125 {songs.map((song) => ( 126 <SongItem 127 key={song.id} 128 song={{ 129 id: song.id, 130 title: song.title, 131 cover: song.cover, 132 genre: song.genre, 133 link: song.link, 134 isLikedByCurrentUser: song.isLikedByCurrentUser, 135 }} 136 role={song.role} 137 onLikeToggle={() => handleLike(song.id, song.title)} 138 isDropdownOpen={openDropdownSongId === song.id} 139 onDropdownToggle={setOpenDropdownSongId} 140 /> 141 ))} 142 </div> 143 </div> 144 )} 140 145 141 {contributions.length === 0 && (142 <div className="flex flex-col items-center justify-center py-16 text-gray-500">143 <Music className="w-20 h-20 mb-4 opacity-20" />144 <p className="text-lg font-medium">No contributions yet</p>145 <p className="text-sm mt-2">Start creating music to see it here</p>146 </div>147 )}148 </div>149 );146 {contributions.length === 0 && ( 147 <div className="flex flex-col items-center justify-center py-16 text-gray-500"> 148 <Music className="w-20 h-20 mb-4 opacity-20" /> 149 <p className="text-lg font-medium">No contributions yet</p> 150 <p className="text-sm mt-2">Start creating music to see it here</p> 151 </div> 152 )} 153 </div> 154 ); 150 155 }; 151 156 -
frontend/src/components/userProfile/ListenerView.tsx
r85512ff rce45c7a 25 25 const [createdItems, setCreatedItems] = useState(createdPlaylists); 26 26 const [savedItems, setSavedItems] = useState(savedPlaylists); 27 const [openDropdownSongId, setOpenDropdownSongId] = useState<number | null>( 28 null, 29 ); 27 30 28 31 const likedSongs = items.filter((e) => e.type === "SONG"); … … 258 261 index={index + 1} 259 262 onLikeToggle={() => handleLike(song.id, song.title)} 263 isDropdownOpen={openDropdownSongId === song.id} 264 onDropdownToggle={setOpenDropdownSongId} 260 265 /> 261 266 ))}
Note:
See TracChangeset
for help on using the changeset viewer.
