Changeset ce45c7a for frontend/src/components/SongItem.tsx
- Timestamp:
- 02/15/26 00:39:55 (5 months ago)
- Branches:
- main
- Children:
- c8baad1
- Parents:
- 85512ff
- File:
-
- 1 edited
-
frontend/src/components/SongItem.tsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
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
Note:
See TracChangeset
for help on using the changeset viewer.
