Changeset ce45c7a for frontend/src/pages
- Timestamp:
- 02/15/26 00:39:55 (5 months ago)
- Branches:
- main
- Children:
- c8baad1
- Parents:
- 85512ff
- Location:
- frontend/src/pages
- Files:
-
- 4 edited
-
LandingPage.tsx (modified) (2 diffs)
-
MusicalCollection.tsx (modified) (2 diffs)
-
SongDetail.tsx (modified) (2 diffs)
-
UserDetail.tsx (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/LandingPage.tsx
r85512ff rce45c7a 1 import { useEffect, use Ref, useState } from "react";1 import { useEffect, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 3 import axiosInstance, { baseURL } from "../api/axiosInstance"; 4 import PlaylistDropdown from "../components/PlaylistDropdown"; 4 5 import AlbumResult from "../components/search/AlbumResult"; 5 6 import SongResult from "../components/search/SongResult"; … … 7 8 import { usePlayer } from "../context/playerContext"; 8 9 import type { 9 Album,10 BaseNonAdminUser,11 SearchCategory,12 Song,10 Album, 11 BaseNonAdminUser, 12 SearchCategory, 13 Song, 13 14 } from "../utils/types"; 14 15 import { toEmbedUrl } from "../utils/utils"; 15 16 16 17 const CATEGORIES: { value: SearchCategory; label: string }[] = [ 17 { value: "songs", label: "Songs" },18 { value: "albums", label: "Albums" },19 { value: "artists", label: "Artists" },20 { value: "users", label: "Users" },18 { value: "songs", label: "Songs" }, 19 { value: "albums", label: "Albums" }, 20 { value: "artists", label: "Artists" }, 21 { value: "users", label: "Users" }, 21 22 ]; 22 23 23 24 const LandingPage = () => { 24 const navigate = useNavigate(); 25 const { play, currentSong } = usePlayer(); 26 const [songs, setSongs] = useState<Song[]>([]); 27 const [loading, setLoading] = useState<boolean>(true); 28 29 // search state 30 const [searchInput, setSearchInput] = useState(""); 31 const [activeQuery, setActiveQuery] = useState(""); 32 const [searchCategory, setSearchCategory] = useState<SearchCategory>("songs"); 33 const [searchResults, setSearchResults] = useState<unknown[]>([]); 34 const [searchLoading, setSearchLoading] = useState(false); 35 const [hasSearched, setHasSearched] = useState(false); 36 37 // playlist dropdown state 38 const [openPlaylistDropdown, setOpenPlaylistDropdown] = useState< 39 number | null 40 >(null); 41 const playlistDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>( 42 {}, 43 ); 44 45 useEffect(() => { 46 const fetchSongs = async () => { 47 try { 48 const response = await axiosInstance.get("/songs/top"); 49 setSongs(response.data); 50 } catch (error) { 51 console.error("Error fetching songs:", error); 52 } finally { 53 setLoading(false); 54 } 55 }; 56 fetchSongs(); 57 }, []); 58 59 // Handle click outside for playlist dropdown 60 useEffect(() => { 61 const handleClickOutside = (event: MouseEvent) => { 62 if (openPlaylistDropdown !== null) { 63 const ref = playlistDropdownRefs.current[openPlaylistDropdown]; 64 if (ref && !ref.contains(event.target as Node)) { 65 setOpenPlaylistDropdown(null); 66 } 67 } 68 }; 69 70 document.addEventListener("mousedown", handleClickOutside); 71 return () => { 72 document.removeEventListener("mousedown", handleClickOutside); 73 }; 74 }, [openPlaylistDropdown]); 75 76 const toggleLike = async (songId: number) => { 77 try { 78 await axiosInstance.post(`/musical-entity/${songId}/like`); 79 setSongs((prevSongs) => 80 prevSongs.map((song) => 81 song.id === songId 82 ? { ...song, isLikedByCurrentUser: !song.isLikedByCurrentUser } 83 : song, 84 ), 85 ); 86 } catch (error) { 87 console.error("Error toggling like:", error); 88 } 89 }; 90 91 const togglePlaylistDropdown = (songId: number) => { 92 setOpenPlaylistDropdown((prev) => (prev === songId ? null : songId)); 93 }; 94 95 const handleAddToPlaylist = (songId: number, playlistName: string) => { 96 console.log(`Adding song ${songId} to ${playlistName}`); 97 // TODO: Implement actual API call 98 setOpenPlaylistDropdown(null); 99 }; 100 101 const handleCreateNewPlaylist = (songId: number) => { 102 console.log(`Creating new playlist for song ${songId}`); 103 // TODO: Implement actual playlist creation 104 setOpenPlaylistDropdown(null); 105 }; 106 107 const performSearch = async (query: string, category: SearchCategory) => { 108 if (!query.trim()) return; 109 110 setSearchLoading(true); 111 setHasSearched(true); 112 setActiveQuery(query); 113 setSearchCategory(category); 114 115 try { 116 let endpoint = ""; 117 switch (category) { 118 case "songs": 119 endpoint = `/songs/search?q=${encodeURIComponent(query)}`; 120 break; 121 case "albums": 122 endpoint = `/albums/search?q=${encodeURIComponent(query)}`; 123 break; 124 case "artists": 125 endpoint = `/users/search?type=ARTIST&q=${encodeURIComponent(query)}`; 126 break; 127 case "users": 128 endpoint = `/users/search?type=LISTENER&q=${encodeURIComponent(query)}`; 129 break; 130 } 131 132 const response = await axiosInstance.get(endpoint); 133 setSearchResults(response.data); 134 } catch (error) { 135 console.error("Search error:", error); 136 setSearchResults([]); 137 } finally { 138 setSearchLoading(false); 139 } 140 }; 141 142 const handleSearch = () => { 143 performSearch(searchInput, searchCategory); 144 }; 145 146 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 147 if (e.key === "Enter") { 148 handleSearch(); 149 } 150 }; 151 152 const handleCategorySwitch = (category: SearchCategory) => { 153 performSearch(activeQuery, category); 154 }; 155 156 const clearSearch = () => { 157 setSearchInput(""); 158 setActiveQuery(""); 159 setHasSearched(false); 160 setSearchResults([]); 161 }; 162 163 const renderResults = () => { 164 if (searchLoading) { 165 return ( 166 <div className="flex justify-center py-12"> 167 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 168 </div> 169 ); 170 } 171 172 if (searchResults.length === 0) { 173 return ( 174 <div className="text-center py-12 text-gray-400"> 175 <p className="text-lg"> 176 No {searchCategory} found for "{activeQuery}" 177 </p> 178 </div> 179 ); 180 } 181 182 return ( 183 <div className="divide-y divide-white/5"> 184 {searchResults.map((result, index) => { 185 switch (searchCategory) { 186 case "songs": 187 return ( 188 <SongResult key={(result as Song).id} song={result as Song} /> 189 ); 190 case "albums": 191 return ( 192 <AlbumResult 193 key={(result as Album).id} 194 album={result as Album} 195 /> 196 ); 197 case "artists": 198 return ( 199 <UserResult 200 key={(result as BaseNonAdminUser).username ?? index} 201 user={result as BaseNonAdminUser} 202 label="Artist" 203 /> 204 ); 205 case "users": 206 return ( 207 <UserResult 208 key={(result as BaseNonAdminUser).username ?? index} 209 user={result as BaseNonAdminUser} 210 label="User" 211 /> 212 ); 213 } 214 })} 215 </div> 216 ); 217 }; 218 219 return ( 220 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white flex"> 221 <div className="flex-1"> 222 <div className="p-8"> 223 <div className="max-w-7xl mx-auto"> 224 {/* Search Bar */} 225 <div className="mb-8 flex flex-col md:flex-row gap-3"> 226 <div className="flex flex-1 gap-0"> 227 {/* Category Dropdown */} 228 <select 229 value={searchCategory} 230 onChange={(e) => 231 setSearchCategory(e.target.value as SearchCategory) 232 } 233 className="bg-[#282828] border border-white/10 border-r-0 rounded-l-full py-2 px-4 text-white text-sm focus:outline-none focus:border-[#1db954] appearance-none cursor-pointer" 234 > 235 {CATEGORIES.map((cat) => ( 236 <option key={cat.value} value={cat.value}> 237 {cat.label} 238 </option> 239 ))} 240 </select> 241 242 {/* Search Input */} 243 <input 244 type="text" 245 placeholder={`Search ${searchCategory}...`} 246 value={searchInput} 247 onChange={(e) => setSearchInput(e.target.value)} 248 onKeyDown={handleKeyDown} 249 className="flex-1 bg-[#282828] border border-white/10 border-r-0 py-2 px-4 text-white focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all" 250 /> 251 252 {/* Search Button */} 253 <button 254 onClick={handleSearch} 255 className="bg-[#1db954] hover:bg-[#1ed760] text-black font-medium px-6 rounded-r-full transition-colors flex items-center gap-2" 256 > 257 <svg 258 className="w-5 h-5" 259 fill="none" 260 stroke="currentColor" 261 viewBox="0 0 24 24" 262 > 263 <path 264 strokeLinecap="round" 265 strokeLinejoin="round" 266 strokeWidth={2} 267 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 268 /> 269 </svg> 270 <span className="hidden sm:inline">Search</span> 271 </button> 272 </div> 273 </div> 274 275 {/* Search Results Section */} 276 {hasSearched ? ( 277 <div> 278 <div className="flex items-center justify-between mb-4"> 279 <h2 className="text-2xl font-bold text-white"> 280 Results for "{activeQuery}" 281 </h2> 282 <button 283 onClick={clearSearch} 284 className="text-sm text-gray-400 hover:text-white transition-colors" 285 > 286 Clear search 287 </button> 288 </div> 289 290 {/* Quick category switch buttons */} 291 <div className="flex gap-2 mb-6"> 292 {CATEGORIES.map((cat) => ( 293 <button 294 key={cat.value} 295 onClick={() => handleCategorySwitch(cat.value)} 296 className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${ 297 searchCategory === cat.value 298 ? "bg-[#1db954] text-black" 299 : "bg-white/10 text-white hover:bg-white/20" 300 }`} 301 > 302 {cat.label} 303 </button> 304 ))} 305 </div> 306 307 {/* Results list */} 308 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden"> 309 {renderResults()} 310 </div> 311 </div> 312 ) : ( 313 /* Default song grid */ 314 <> 315 <div className="mb-8 border-b border-white/10 pb-6"> 316 <h1 className="text-5xl font-bold mb-3 pb-1 bg-linear-to-r from-[#1db954] to-[#1ed760] bg-clip-text text-transparent"> 317 Top Songs 318 </h1> 319 <p className="text-xl text-gray-400"> 320 Listen to the newest tracks on FinkWave 321 </p> 322 </div> 323 324 {loading ? ( 325 <div className="flex flex-col items-center justify-center min-h-[40vh] gap-6"> 326 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 327 <p className="text-xl text-gray-400">Loading songs...</p> 328 </div> 329 ) : ( 330 <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-8 py-4"> 331 {songs.map((song) => ( 332 <div 333 key={song.id} 334 onClick={() => navigate(`/songs/${song.id}`)} 335 className="bg-[#282828] rounded-xl overflow-hidden cursor-pointer shadow-lg transition-all duration-300 ease-in-out hover:-translate-y-2 hover:shadow-2xl group" 336 > 337 <div className="relative w-full pt-[100%] overflow-hidden bg-[#181818]"> 338 <img 339 src={ 340 song.cover 341 ? `${baseURL}/${song.cover}` 342 : "/favicon.png" 343 } 344 alt={song.title} 345 className="absolute top-0 left-0 w-full h-full object-cover" 346 onError={(e) => { 347 (e.target as HTMLImageElement).src = 348 "/favicon.png"; 349 }} 350 /> 351 <div className="absolute top-0 left-0 w-full h-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 352 {song.link ? ( 353 <button 354 onClick={(e) => { 355 e.stopPropagation(); 356 play({ 357 id: song.id, 358 title: song.title, 359 artist: song.releasedBy, 360 cover: song.cover, 361 embedUrl: toEmbedUrl(song.link!), 362 }); 363 }} 364 className={`w-14 h-14 rounded-full flex items-center justify-center text-xl shadow-[0_4px_12px_rgba(29,185,84,0.5)] transition-all cursor-pointer ${ 365 currentSong?.id === song.id 366 ? "bg-white text-[#1db954]" 367 : "bg-[#1db954] text-black hover:scale-105" 368 }`} 369 aria-label={ 370 currentSong?.id === song.id 371 ? "Now playing" 372 : "Play song" 373 } 374 > 375 {currentSong?.id === song.id ? ( 376 <svg 377 className="w-6 h-6" 378 fill="currentColor" 379 viewBox="0 0 24 24" 380 > 381 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 382 </svg> 383 ) : ( 384 <svg 385 className="w-6 h-6" 386 fill="currentColor" 387 viewBox="0 0 24 24" 388 > 389 <path d="M8 5v14l11-7z" /> 390 </svg> 391 )} 392 </button> 393 ) : ( 394 <div className="w-14 h-14 rounded-full bg-[#1db954] flex items-center justify-center text-xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 395 ▶ 396 </div> 397 )} 398 </div> 399 </div> 400 <div className="p-4"> 401 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap"> 402 {song.title} 403 </h3> 404 {song.album && ( 405 <p 406 onClick={(e) => { 407 e.stopPropagation(); 408 if (song.albumId) { 409 navigate(`/collection/album/${song.albumId}`); 410 } 411 }} 412 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${ 413 song.albumId 414 ? "hover:underline cursor-pointer hover:text-white" 415 : "" 416 }`} 417 > 418 {song.album} 419 </p> 420 )} 421 <p 422 onClick={(e) => { 423 e.stopPropagation(); 424 if (song.artistUsername) { 425 navigate(`/users/${song.artistUsername}`); 426 } 427 }} 428 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${ 429 song.artistUsername 430 ? "hover:underline cursor-pointer hover:text-white" 431 : "" 432 }`} 433 > 434 {song.releasedBy} 435 </p> 436 <div className="flex items-center justify-between mt-4 pt-3 border-t border-white/10"> 437 {/* Like button */} 438 <button 439 onClick={(e) => { 440 e.stopPropagation(); 441 toggleLike(song.id); 442 }} 443 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer" 444 aria-label={ 445 song.isLikedByCurrentUser 446 ? "Unlike song" 447 : "Like song" 448 } 449 > 450 {song.isLikedByCurrentUser ? ( 451 <svg 452 className="w-6 h-6 fill-[#1db954]" 453 viewBox="0 0 24 24" 454 > 455 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> 456 </svg> 457 ) : ( 458 <svg 459 className="w-6 h-6" 460 fill="none" 461 stroke="currentColor" 462 viewBox="0 0 24 24" 463 > 464 <path 465 strokeLinecap="round" 466 strokeLinejoin="round" 467 strokeWidth={2} 468 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 469 /> 470 </svg> 471 )} 472 </button> 473 474 {/* Add to playlist button */} 475 <div 476 className="relative" 477 ref={(el) => { 478 if (el) 479 playlistDropdownRefs.current[song.id] = el; 480 }} 481 > 482 <button 483 onClick={(e) => { 484 e.stopPropagation(); 485 togglePlaylistDropdown(song.id); 486 }} 487 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer" 488 aria-label="Add to playlist" 489 > 490 <svg 491 className="w-6 h-6" 492 fill="none" 493 stroke="currentColor" 494 viewBox="0 0 24 24" 495 > 496 <path 497 strokeLinecap="round" 498 strokeLinejoin="round" 499 strokeWidth={2} 500 d="M12 4v16m8-8H4" 501 /> 502 </svg> 503 </button> 504 505 {/* Playlist dropdown */} 506 {openPlaylistDropdown === song.id && ( 507 <div className="absolute right-0 bottom-full mb-2 w-48 bg-gray-700 rounded-lg shadow-lg py-1 z-50"> 508 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10"> 509 Add to playlist 510 </div> 511 <button 512 onClick={(e) => { 513 e.stopPropagation(); 514 handleAddToPlaylist( 515 song.id, 516 "Playlist 1", 517 ); 518 }} 519 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors" 520 > 521 Playlist 1 522 </button> 523 <button 524 onClick={(e) => { 525 e.stopPropagation(); 526 handleAddToPlaylist( 527 song.id, 528 "Playlist 2", 529 ); 530 }} 531 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors" 532 > 533 Playlist 2 534 </button> 535 <button 536 onClick={(e) => { 537 e.stopPropagation(); 538 handleCreateNewPlaylist(song.id); 539 }} 540 className="w-full text-left px-4 py-2 text-sm text-[#1db954] hover:bg-gray-600 transition-colors border-t border-white/10 flex items-center gap-2" 541 > 542 <svg 543 className="w-4 h-4" 544 fill="none" 545 stroke="currentColor" 546 viewBox="0 0 24 24" 547 > 548 <path 549 strokeLinecap="round" 550 strokeLinejoin="round" 551 strokeWidth={2} 552 d="M12 4v16m8-8H4" 553 /> 554 </svg> 555 Create new playlist 556 </button> 557 </div> 558 )} 559 </div> 560 </div> 561 </div> 562 </div> 563 ))} 564 </div> 565 )} 566 </> 567 )} 568 </div> 569 </div> 570 </div> 571 </div> 572 ); 25 const navigate = useNavigate(); 26 const { play, currentSong } = usePlayer(); 27 const [songs, setSongs] = useState<Song[]>([]); 28 const [loading, setLoading] = useState<boolean>(true); 29 30 // search state 31 const [searchInput, setSearchInput] = useState(""); 32 const [activeQuery, setActiveQuery] = useState(""); 33 const [searchCategory, setSearchCategory] = useState<SearchCategory>("songs"); 34 const [searchResults, setSearchResults] = useState<unknown[]>([]); 35 const [searchLoading, setSearchLoading] = useState(false); 36 const [hasSearched, setHasSearched] = useState(false); 37 38 // playlist dropdown state 39 const [openPlaylistDropdown, setOpenPlaylistDropdown] = useState< 40 number | null 41 >(null); 42 43 useEffect(() => { 44 const fetchSongs = async () => { 45 try { 46 const response = await axiosInstance.get("/songs/top"); 47 setSongs(response.data); 48 } catch (error) { 49 console.error("Error fetching songs:", error); 50 } finally { 51 setLoading(false); 52 } 53 }; 54 fetchSongs(); 55 }, []); 56 57 const toggleLike = async (songId: number) => { 58 try { 59 await axiosInstance.post(`/musical-entity/${songId}/like`); 60 setSongs((prevSongs) => 61 prevSongs.map((song) => 62 song.id === songId 63 ? { ...song, isLikedByCurrentUser: !song.isLikedByCurrentUser } 64 : song, 65 ), 66 ); 67 } catch (error) { 68 console.error("Error toggling like:", error); 69 } 70 }; 71 72 const togglePlaylistDropdown = (songId: number) => { 73 if (openPlaylistDropdown === songId) { 74 setOpenPlaylistDropdown(null); 75 } else { 76 setOpenPlaylistDropdown(songId); 77 } 78 }; 79 80 const handleCreateNewPlaylist = (songId: number) => { 81 console.log(`Creating new playlist for song ${songId}`); 82 // TODO: Implement actual playlist creation 83 setOpenPlaylistDropdown(null); 84 }; 85 86 const performSearch = async (query: string, category: SearchCategory) => { 87 if (!query.trim()) return; 88 89 setSearchLoading(true); 90 setHasSearched(true); 91 setActiveQuery(query); 92 setSearchCategory(category); 93 94 try { 95 let endpoint = ""; 96 switch (category) { 97 case "songs": 98 endpoint = `/songs/search?q=${encodeURIComponent(query)}`; 99 break; 100 case "albums": 101 endpoint = `/albums/search?q=${encodeURIComponent(query)}`; 102 break; 103 case "artists": 104 endpoint = `/users/search?type=ARTIST&q=${encodeURIComponent(query)}`; 105 break; 106 case "users": 107 endpoint = `/users/search?type=LISTENER&q=${encodeURIComponent(query)}`; 108 break; 109 } 110 111 const response = await axiosInstance.get(endpoint); 112 setSearchResults(response.data); 113 } catch (error) { 114 console.error("Search error:", error); 115 setSearchResults([]); 116 } finally { 117 setSearchLoading(false); 118 } 119 }; 120 121 const handleSearch = () => { 122 performSearch(searchInput, searchCategory); 123 }; 124 125 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 126 if (e.key === "Enter") { 127 handleSearch(); 128 } 129 }; 130 131 const handleCategorySwitch = (category: SearchCategory) => { 132 performSearch(activeQuery, category); 133 }; 134 135 const clearSearch = () => { 136 setSearchInput(""); 137 setActiveQuery(""); 138 setHasSearched(false); 139 setSearchResults([]); 140 }; 141 142 const renderResults = () => { 143 if (searchLoading) { 144 return ( 145 <div className="flex justify-center py-12"> 146 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 147 </div> 148 ); 149 } 150 151 if (searchResults.length === 0) { 152 return ( 153 <div className="text-center py-12 text-gray-400"> 154 <p className="text-lg"> 155 No {searchCategory} found for "{activeQuery}" 156 </p> 157 </div> 158 ); 159 } 160 161 return ( 162 <div className="divide-y divide-white/5"> 163 {searchResults.map((result, index) => { 164 switch (searchCategory) { 165 case "songs": 166 return ( 167 <SongResult key={(result as Song).id} song={result as Song} /> 168 ); 169 case "albums": 170 return ( 171 <AlbumResult 172 key={(result as Album).id} 173 album={result as Album} 174 /> 175 ); 176 case "artists": 177 return ( 178 <UserResult 179 key={(result as BaseNonAdminUser).username ?? index} 180 user={result as BaseNonAdminUser} 181 label="Artist" 182 /> 183 ); 184 case "users": 185 return ( 186 <UserResult 187 key={(result as BaseNonAdminUser).username ?? index} 188 user={result as BaseNonAdminUser} 189 label="User" 190 /> 191 ); 192 } 193 })} 194 </div> 195 ); 196 }; 197 198 return ( 199 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white flex"> 200 <div className="flex-1"> 201 <div className="p-8"> 202 <div className="max-w-7xl mx-auto"> 203 {/* Search Bar */} 204 <div className="mb-8 flex flex-col md:flex-row gap-3"> 205 <div className="flex flex-1 gap-0"> 206 {/* Category Dropdown */} 207 <select 208 value={searchCategory} 209 onChange={(e) => 210 setSearchCategory(e.target.value as SearchCategory) 211 } 212 className="bg-[#282828] border border-white/10 border-r-0 rounded-l-full py-2 px-4 text-white text-sm focus:outline-none focus:border-[#1db954] appearance-none cursor-pointer" 213 > 214 {CATEGORIES.map((cat) => ( 215 <option key={cat.value} value={cat.value}> 216 {cat.label} 217 </option> 218 ))} 219 </select> 220 221 {/* Search Input */} 222 <input 223 type="text" 224 placeholder={`Search ${searchCategory}...`} 225 value={searchInput} 226 onChange={(e) => setSearchInput(e.target.value)} 227 onKeyDown={handleKeyDown} 228 className="flex-1 bg-[#282828] border border-white/10 border-r-0 py-2 px-4 text-white focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all" 229 /> 230 231 {/* Search Button */} 232 <button 233 onClick={handleSearch} 234 className="bg-[#1db954] hover:bg-[#1ed760] text-black font-medium px-6 rounded-r-full transition-colors flex items-center gap-2" 235 > 236 <svg 237 className="w-5 h-5" 238 fill="none" 239 stroke="currentColor" 240 viewBox="0 0 24 24" 241 > 242 <path 243 strokeLinecap="round" 244 strokeLinejoin="round" 245 strokeWidth={2} 246 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 247 /> 248 </svg> 249 <span className="hidden sm:inline">Search</span> 250 </button> 251 </div> 252 </div> 253 254 {/* Search Results Section */} 255 {hasSearched ? ( 256 <div> 257 <div className="flex items-center justify-between mb-4"> 258 <h2 className="text-2xl font-bold text-white"> 259 Results for "{activeQuery}" 260 </h2> 261 <button 262 onClick={clearSearch} 263 className="text-sm text-gray-400 hover:text-white transition-colors" 264 > 265 Clear search 266 </button> 267 </div> 268 269 {/* Quick category switch buttons */} 270 <div className="flex gap-2 mb-6"> 271 {CATEGORIES.map((cat) => ( 272 <button 273 key={cat.value} 274 onClick={() => handleCategorySwitch(cat.value)} 275 className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${ 276 searchCategory === cat.value 277 ? "bg-[#1db954] text-black" 278 : "bg-white/10 text-white hover:bg-white/20" 279 }`} 280 > 281 {cat.label} 282 </button> 283 ))} 284 </div> 285 286 {/* Results list */} 287 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden"> 288 {renderResults()} 289 </div> 290 </div> 291 ) : ( 292 /* Default song grid */ 293 <> 294 <div className="mb-8 border-b border-white/10 pb-6"> 295 <h1 className="text-5xl font-bold mb-3 pb-1 bg-linear-to-r from-[#1db954] to-[#1ed760] bg-clip-text text-transparent"> 296 Top Songs 297 </h1> 298 <p className="text-xl text-gray-400"> 299 Listen to the newest tracks on FinkWave 300 </p> 301 </div> 302 303 {loading ? ( 304 <div className="flex flex-col items-center justify-center min-h-[40vh] gap-6"> 305 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 306 <p className="text-xl text-gray-400">Loading songs...</p> 307 </div> 308 ) : ( 309 <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-8 py-4"> 310 {songs.map((song) => ( 311 <div 312 key={song.id} 313 onClick={() => navigate(`/songs/${song.id}`)} 314 onMouseEnter={() => { 315 if ( 316 openPlaylistDropdown !== null && 317 openPlaylistDropdown !== song.id 318 ) { 319 setOpenPlaylistDropdown(null); 320 } 321 }} 322 className="bg-[#282828] rounded-xl cursor-pointer shadow-lg transition-all duration-300 ease-in-out hover:-translate-y-2 hover:shadow-2xl group relative" 323 > 324 <div className="relative w-full pt-[100%] overflow-hidden bg-[#181818] rounded-t-xl"> 325 <img 326 src={ 327 song.cover 328 ? `${baseURL}/${song.cover}` 329 : "/favicon.png" 330 } 331 alt={song.title} 332 className="absolute top-0 left-0 w-full h-full object-cover" 333 onError={(e) => { 334 (e.target as HTMLImageElement).src = 335 "/favicon.png"; 336 }} 337 /> 338 <div className="absolute top-0 left-0 w-full h-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 339 {song.link ? ( 340 <button 341 onClick={(e) => { 342 e.stopPropagation(); 343 play({ 344 id: song.id, 345 title: song.title, 346 artist: song.releasedBy, 347 cover: song.cover, 348 embedUrl: toEmbedUrl(song.link!), 349 }); 350 }} 351 className={`w-14 h-14 rounded-full flex items-center justify-center text-xl shadow-[0_4px_12px_rgba(29,185,84,0.5)] transition-all cursor-pointer ${ 352 currentSong?.id === song.id 353 ? "bg-white text-[#1db954]" 354 : "bg-[#1db954] text-black hover:scale-105" 355 }`} 356 aria-label={ 357 currentSong?.id === song.id 358 ? "Now playing" 359 : "Play song" 360 } 361 > 362 {currentSong?.id === song.id ? ( 363 <svg 364 className="w-6 h-6" 365 fill="currentColor" 366 viewBox="0 0 24 24" 367 > 368 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 369 </svg> 370 ) : ( 371 <svg 372 className="w-6 h-6" 373 fill="currentColor" 374 viewBox="0 0 24 24" 375 > 376 <path d="M8 5v14l11-7z" /> 377 </svg> 378 )} 379 </button> 380 ) : ( 381 <div className="w-14 h-14 rounded-full bg-[#1db954] flex items-center justify-center text-xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 382 ▶ 383 </div> 384 )} 385 </div> 386 </div> 387 <div className="p-4"> 388 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap"> 389 {song.title} 390 </h3> 391 {song.album && ( 392 <p 393 onClick={(e) => { 394 e.stopPropagation(); 395 if (song.albumId) { 396 navigate(`/collection/album/${song.albumId}`); 397 } 398 }} 399 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${ 400 song.albumId 401 ? "hover:underline cursor-pointer hover:text-white" 402 : "" 403 }`} 404 > 405 {song.album} 406 </p> 407 )} 408 <p 409 onClick={(e) => { 410 e.stopPropagation(); 411 if (song.artistUsername) { 412 navigate(`/users/${song.artistUsername}`); 413 } 414 }} 415 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${ 416 song.artistUsername 417 ? "hover:underline cursor-pointer hover:text-white" 418 : "" 419 }`} 420 > 421 {song.releasedBy} 422 </p> 423 <div className="flex items-center justify-between mt-4 pt-3 border-t border-white/10"> 424 {/* Like button */} 425 <button 426 onClick={(e) => { 427 e.stopPropagation(); 428 toggleLike(song.id); 429 }} 430 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer" 431 aria-label={ 432 song.isLikedByCurrentUser 433 ? "Unlike song" 434 : "Like song" 435 } 436 > 437 {song.isLikedByCurrentUser ? ( 438 <svg 439 className="w-6 h-6 fill-[#1db954]" 440 viewBox="0 0 24 24" 441 > 442 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> 443 </svg> 444 ) : ( 445 <svg 446 className="w-6 h-6" 447 fill="none" 448 stroke="currentColor" 449 viewBox="0 0 24 24" 450 > 451 <path 452 strokeLinecap="round" 453 strokeLinejoin="round" 454 strokeWidth={2} 455 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 456 /> 457 </svg> 458 )} 459 </button> 460 461 {/* Add to playlist button */} 462 <div className="relative"> 463 <button 464 onClick={(e) => { 465 e.stopPropagation(); 466 togglePlaylistDropdown(song.id); 467 }} 468 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer" 469 aria-label="Add to playlist" 470 > 471 <svg 472 className="w-6 h-6" 473 fill="none" 474 stroke="currentColor" 475 viewBox="0 0 24 24" 476 > 477 <path 478 strokeLinecap="round" 479 strokeLinejoin="round" 480 strokeWidth={2} 481 d="M12 4v16m8-8H4" 482 /> 483 </svg> 484 </button> 485 486 <PlaylistDropdown 487 songId={song.id} 488 isOpen={openPlaylistDropdown === song.id} 489 onClose={() => setOpenPlaylistDropdown(null)} 490 onCreateNewPlaylist={() => 491 handleCreateNewPlaylist(song.id) 492 } 493 direction="above" 494 /> 495 </div> 496 </div> 497 </div> 498 </div> 499 ))} 500 </div> 501 )} 502 </> 503 )} 504 </div> 505 </div> 506 </div> 507 </div> 508 ); 573 509 }; 574 510 -
frontend/src/pages/MusicalCollection.tsx
r85512ff rce45c7a 21 21 const [isLoading, setIsLoading] = useState(true); 22 22 const [error, setError] = useState<string | null>(null); 23 const [openDropdownSongId, setOpenDropdownSongId] = useState<number | null>( 24 null, 25 ); 23 26 24 27 const normalizeCollection = ( … … 236 239 index={index + 1} 237 240 onLikeToggle={() => toggleLike(song.id)} 241 isDropdownOpen={openDropdownSongId === song.id} 242 onDropdownToggle={setOpenDropdownSongId} 238 243 /> 239 244 ))} -
frontend/src/pages/SongDetail.tsx
r85512ff rce45c7a 1 import { useEffect, use Ref, useState } from "react";1 import { useEffect, useState } from "react"; 2 2 import { Link, useParams } from "react-router-dom"; 3 3 import { toast } from "react-toastify"; 4 4 import axiosInstance, { baseURL } from "../api/axiosInstance"; 5 import PlaylistDropdown from "../components/PlaylistDropdown"; 5 6 import { useAuth } from "../context/authContext"; 6 7 import { usePlayer } from "../context/playerContext"; … … 9 10 10 11 const ROLE_LABELS: Record<string, string> = { 11 MAIN_VOCAL: "Main Vocal",12 FEATURED: "Featured",13 PRODUCER: "Producer",14 SONGWRITER: "Songwriter",15 COMPOSER: "Composer",16 MIXER: "Mixer",17 ENGINEER: "Engineer",12 MAIN_VOCAL: "Main Vocal", 13 FEATURED: "Featured", 14 PRODUCER: "Producer", 15 SONGWRITER: "Songwriter", 16 COMPOSER: "Composer", 17 MIXER: "Mixer", 18 ENGINEER: "Engineer", 18 19 }; 19 20 20 21 const formatRole = (role: string): string => { 21 return ROLE_LABELS[role] || role.replace(/_/g, " ");22 return ROLE_LABELS[role] || role.replace(/_/g, " "); 22 23 }; 23 24 24 25 const renderStars = (grade: number) => { 25 return (26 <div className="flex gap-0.5">27 {[1, 2, 3, 4, 5].map((star) => (28 <svg29 key={star}30 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`}31 fill="currentColor"32 viewBox="0 0 20 20"33 >34 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />35 </svg>36 ))}37 </div>38 );26 return ( 27 <div className="flex gap-0.5"> 28 {[1, 2, 3, 4, 5].map((star) => ( 29 <svg 30 key={star} 31 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`} 32 fill="currentColor" 33 viewBox="0 0 20 20" 34 > 35 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 36 </svg> 37 ))} 38 </div> 39 ); 39 40 }; 40 41 41 42 const SongDetail = () => { 42 const { id } = useParams<{ id: string }>(); 43 const { user } = useAuth(); 44 const { play, currentSong } = usePlayer(); 45 const [song, setSong] = useState<SongDetailType | null>(null); 46 const [loading, setLoading] = useState(true); 47 const [error, setError] = useState<string | null>(null); 48 const [showReviewModal, setShowReviewModal] = useState(false); 49 const [reviewRating, setReviewRating] = useState(0); 50 const [reviewComment, setReviewComment] = useState(""); 51 const [hoverRating, setHoverRating] = useState(0); 52 const [showPlaylistDropdown, setShowPlaylistDropdown] = useState(false); 53 const playlistDropdownRef = useRef<HTMLDivElement>(null); 54 console.log(user); 55 useEffect(() => { 56 const fetchSong = async () => { 57 try { 58 setLoading(true); 59 const response = await axiosInstance.get(`/songs/${id}/details`); 60 setSong(response.data); 61 } catch (err) { 62 console.error("Error fetching song details:", err); 63 setError("Failed to load song details."); 64 } finally { 65 setLoading(false); 66 } 67 }; 68 if (id) fetchSong(); 69 }, [id]); 70 71 // handle click outside for playlist dropdown 72 useEffect(() => { 73 const handleClickOutside = (event: MouseEvent) => { 74 if ( 75 playlistDropdownRef.current && 76 !playlistDropdownRef.current.contains(event.target as Node) 77 ) { 78 setShowPlaylistDropdown(false); 79 } 80 }; 81 82 document.addEventListener("mousedown", handleClickOutside); 83 return () => { 84 document.removeEventListener("mousedown", handleClickOutside); 85 }; 86 }, []); 87 88 const handleSubmitReview = async () => { 89 if (reviewRating === 0) { 90 // todo: replace with toast 91 alert("Please select a rating"); 92 return; 93 } 94 try { 95 await axiosInstance.post(`reviews/${song?.id}`, { 96 grade: reviewRating, 97 comment: reviewComment, 98 }); 99 const response = await axiosInstance.get(`/songs/${id}/details`); 100 setSong(response.data); 101 } catch (err) { 102 // todo: replace with toast 103 console.error("Error submitting review:", err); 104 } finally { 105 setShowReviewModal(false); 106 setReviewRating(0); 107 setReviewComment(""); 108 setHoverRating(0); 109 } 110 }; 111 112 const deleteReview = async () => { 113 try { 114 await axiosInstance.delete(`reviews/${song?.id}`); 115 const response = await axiosInstance.get(`/songs/${id}/details`); 116 setSong(response.data); 117 } catch (err) { 118 toast.error("Failed to delete review"); 119 console.error("Error deleting review:", err); 120 } 121 }; 122 123 const toggleLike = async () => { 124 if (!song) return; 125 try { 126 await axiosInstance.post(`/musical-entity/${song.id}/like`); 127 setSong((prev) => 128 prev 129 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser } 130 : prev, 131 ); 132 } catch (err) { 133 toast.error("Failed to toggle like"); 134 console.error("Error toggling like:", err); 135 } 136 }; 137 138 const handleAddToPlaylist = (playlistName: string) => { 139 console.log(`Adding song ${song?.id} to ${playlistName}`); 140 // TODO: actual API call 141 setShowPlaylistDropdown(false); 142 }; 143 144 const handleCreateNewPlaylist = () => { 145 console.log(`Creating new playlist for song ${song?.id}`); 146 // TODO: actual playlist creation 147 setShowPlaylistDropdown(false); 148 }; 149 150 if (loading) { 151 return ( 152 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 153 <div className="flex flex-col items-center gap-4"> 154 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 155 <p className="text-gray-400 text-lg">Loading song…</p> 156 </div> 157 </div> 158 ); 159 } 160 161 if (error || !song) { 162 return ( 163 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 164 <div className="text-center"> 165 <p className="text-red-400 text-xl mb-4"> 166 {error ?? "Song not found"} 167 </p> 168 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 169 ← Back to Home 170 </Link> 171 </div> 172 </div> 173 ); 174 } 175 176 const otherContributors = song.contributions.filter( 177 (c) => c.artistName !== song.releasedBy, 178 ); 179 180 const avgRating = 181 song.reviews.length > 0 182 ? ( 183 song.reviews.reduce((sum, r) => sum + r.grade, 0) / 184 song.reviews.length 185 ).toFixed(1) 186 : null; 187 188 return ( 189 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 190 <div className="max-w-5xl mx-auto px-6 py-10"> 191 {/* Back link */} 192 <Link 193 to="/" 194 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 195 > 196 <svg 197 className="w-4 h-4" 198 fill="none" 199 stroke="currentColor" 200 viewBox="0 0 24 24" 201 > 202 <path 203 strokeLinecap="round" 204 strokeLinejoin="round" 205 strokeWidth={2} 206 d="M15 19l-7-7 7-7" 207 /> 208 </svg> 209 Back to Home 210 </Link> 211 212 {/* Hero section */} 213 <div className="flex flex-col md:flex-row gap-8 mb-10"> 214 {/* Cover art */} 215 <div className="w-full md:w-72 shrink-0"> 216 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 217 <img 218 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 219 alt={song.title} 220 className="absolute inset-0 w-full h-full object-cover" 221 onError={(e) => { 222 (e.target as HTMLImageElement).src = "/favicon.png"; 223 }} 224 /> 225 </div> 226 </div> 227 228 {/* Song info */} 229 <div className="flex flex-col justify-end gap-3 min-w-0"> 230 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 231 {song.genre} • Song 232 </span> 233 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 234 {song.title} 235 </h1> 236 237 {/* Main artist */} 238 <p className="text-xl text-gray-300 font-semibold"> 239 {song.releasedBy} 240 </p> 241 242 {/* Album */} 243 {song.album && ( 244 <p className="text-sm text-gray-500"> 245 Album: <span className="text-gray-300">{song.album}</span> 246 </p> 247 )} 248 249 {/* Rating summary */} 250 {avgRating && ( 251 <div className="flex items-center gap-2 mt-1"> 252 {renderStars(Math.round(Number(avgRating)))} 253 <span className="text-sm text-gray-400"> 254 {avgRating} · {song.reviews.length}{" "} 255 {song.reviews.length === 1 ? "review" : "reviews"} 256 </span> 257 </div> 258 )} 259 260 {/* Action buttons */} 261 <div className="flex items-center gap-3 mt-4"> 262 {/* Play button */} 263 {song.link && ( 264 <button 265 onClick={() => 266 play({ 267 id: song.id, 268 title: song.title, 269 artist: song.releasedBy, 270 cover: song.cover, 271 embedUrl: toEmbedUrl(song.link!), 272 }) 273 } 274 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 275 currentSong?.id === song.id 276 ? "bg-white text-[#1db954]" 277 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 278 }`} 279 > 280 {currentSong?.id === song.id ? ( 281 <> 282 <svg 283 className="w-5 h-5" 284 fill="currentColor" 285 viewBox="0 0 24 24" 286 > 287 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 288 </svg> 289 Now Playing 290 </> 291 ) : ( 292 <> 293 <svg 294 className="w-5 h-5" 295 fill="currentColor" 296 viewBox="0 0 24 24" 297 > 298 <path d="M8 5v14l11-7z" /> 299 </svg> 300 Play Song 301 </> 302 )} 303 </button> 304 )} 305 306 {user && ( 307 <> 308 <button 309 onClick={toggleLike} 310 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 311 song.isLikedByCurrentUser 312 ? "bg-[#1db954] text-black" 313 : "bg-white/10 text-white hover:bg-white/20" 314 }`} 315 > 316 {song.isLikedByCurrentUser ? ( 317 <svg 318 className="w-5 h-5" 319 fill="currentColor" 320 viewBox="0 0 24 24" 321 > 322 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> 323 </svg> 324 ) : ( 325 <svg 326 className="w-5 h-5" 327 fill="none" 328 stroke="currentColor" 329 viewBox="0 0 24 24" 330 > 331 <path 332 strokeLinecap="round" 333 strokeLinejoin="round" 334 strokeWidth={2} 335 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 336 /> 337 </svg> 338 )} 339 {song.isLikedByCurrentUser ? "Liked" : "Like"} 340 </button> 341 342 {/* Add to Playlist button */} 343 <div className="relative" ref={playlistDropdownRef}> 344 <button 345 onClick={() => 346 setShowPlaylistDropdown(!showPlaylistDropdown) 347 } 348 className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold bg-white/10 text-white hover:bg-white/20 transition-colors cursor-pointer" 349 > 350 <svg 351 className="w-5 h-5" 352 fill="none" 353 stroke="currentColor" 354 viewBox="0 0 24 24" 355 > 356 <path 357 strokeLinecap="round" 358 strokeLinejoin="round" 359 strokeWidth={2} 360 d="M12 4v16m8-8H4" 361 /> 362 </svg> 363 Add to Playlist 364 </button> 365 366 {/* Playlist dropdown */} 367 {showPlaylistDropdown && ( 368 <div className="absolute left-0 top-full mt-2 w-56 bg-[#282828] rounded-lg shadow-2xl py-1 z-50 border border-white/10"> 369 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10"> 370 Add to playlist 371 </div> 372 <button 373 onClick={() => handleAddToPlaylist("Hello")} 374 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors" 375 > 376 Hello 377 </button> 378 <button 379 onClick={() => handleAddToPlaylist("Dimi")} 380 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors" 381 > 382 Dimi 383 </button> 384 <button 385 onClick={() => handleAddToPlaylist("lmao")} 386 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors" 387 > 388 Kako hello world ama ti si mojot world 389 </button> 390 <button 391 onClick={handleCreateNewPlaylist} 392 className="w-full text-left px-4 py-2.5 text-sm text-[#1db954] hover:bg-white/10 transition-colors border-t border-white/10 flex items-center gap-2" 393 > 394 <svg 395 className="w-4 h-4" 396 fill="none" 397 stroke="currentColor" 398 viewBox="0 0 24 24" 399 > 400 <path 401 strokeLinecap="round" 402 strokeLinejoin="round" 403 strokeWidth={2} 404 d="M12 4v16m8-8H4" 405 /> 406 </svg> 407 Create new playlist 408 </button> 409 </div> 410 )} 411 </div> 412 </> 413 )} 414 </div> 415 </div> 416 </div> 417 418 {/* Credits / Contributions */} 419 {song.contributions.length > 0 && ( 420 <section className="mb-10"> 421 <h2 className="text-2xl font-bold mb-4">Credits</h2> 422 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5"> 423 {/* Main artist first */} 424 {song.contributions 425 .filter((c) => c.artistName === song.releasedBy) 426 .map((c, i) => ( 427 <div 428 key={`main-${i}`} 429 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 430 > 431 <div className="flex items-center gap-3"> 432 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm"> 433 {c.artistName.charAt(0).toUpperCase()} 434 </div> 435 <div> 436 <p className="text-white font-semibold text-lg"> 437 {c.artistName} 438 </p> 439 </div> 440 </div> 441 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full"> 442 {formatRole(c.role)} 443 </span> 444 </div> 445 ))} 446 447 {/* Other contributors */} 448 {otherContributors.map((c, i) => ( 449 <div 450 key={`contrib-${i}`} 451 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 452 > 453 <div className="flex items-center gap-3"> 454 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm"> 455 {c.artistName.charAt(0).toUpperCase()} 456 </div> 457 <p className="text-gray-300 font-medium">{c.artistName}</p> 458 </div> 459 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full"> 460 {formatRole(c.role)} 461 </span> 462 </div> 463 ))} 464 </div> 465 </section> 466 )} 467 468 {/* Reviews */} 469 <section> 470 <div className="flex items-center justify-between mb-4"> 471 <h2 className="text-2xl font-bold"> 472 Reviews 473 {song.reviews.length > 0 && ( 474 <span className="text-base font-normal text-gray-500 ml-2"> 475 ({song.reviews.length}) 476 </span> 477 )} 478 </h2> 479 <button 480 onClick={() => setShowReviewModal(true)} 481 className="flex items-center gap-2 bg-[#1db954] hover:bg-[#1ed760] text-black text-sm font-semibold px-4 py-2 rounded-full transition-colors" 482 > 483 <svg 484 className="w-4 h-4" 485 fill="none" 486 stroke="currentColor" 487 viewBox="0 0 24 24" 488 > 489 <path 490 strokeLinecap="round" 491 strokeLinejoin="round" 492 strokeWidth={2} 493 d="M12 4v16m8-8H4" 494 /> 495 </svg> 496 Add Review 497 </button> 498 </div> 499 500 {song.reviews.length === 0 ? ( 501 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center"> 502 <p className="text-gray-500 text-lg">No reviews yet.</p> 503 <p className="text-gray-600 text-sm mt-1"> 504 Be the first to share your thoughts! 505 </p> 506 </div> 507 ) : ( 508 <div className="space-y-4"> 509 {song.reviews.map((review) => ( 510 <div 511 key={`${review.id.listenerId}-${review.id.musicalEntityId}`} 512 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors" 513 > 514 <div className="flex items-start justify-between mb-2"> 515 <div className="flex items-center gap-3"> 516 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center"> 517 <span className="text-blue-400 font-semibold text-sm"> 518 {review.author.charAt(0).toUpperCase()} 519 </span> 520 </div> 521 <div> 522 <p className="text-white font-medium"> 523 {review.author} 524 </p> 525 {renderStars(review.grade)} 526 </div> 527 </div> 528 {user?.username === review.authorUsername && ( 529 <button 530 onClick={deleteReview} 531 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10" 532 title="Delete review" 533 > 534 <svg 535 className="w-5 h-5" 536 fill="none" 537 stroke="currentColor" 538 viewBox="0 0 24 24" 539 > 540 <path 541 strokeLinecap="round" 542 strokeLinejoin="round" 543 strokeWidth={2} 544 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 545 /> 546 </svg> 547 </button> 548 )} 549 </div> 550 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12"> 551 {review.comment} 552 </p> 553 </div> 554 ))} 555 </div> 556 )} 557 </section> 558 </div> 559 560 {/* Review Modal */} 561 {showReviewModal && ( 562 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> 563 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl"> 564 <div className="flex items-center justify-between mb-6"> 565 <h3 className="text-2xl font-bold">Add Review</h3> 566 <button 567 onClick={() => { 568 setShowReviewModal(false); 569 setReviewRating(0); 570 setReviewComment(""); 571 setHoverRating(0); 572 }} 573 className="text-gray-400 hover:text-white transition-colors" 574 > 575 <svg 576 className="w-6 h-6" 577 fill="none" 578 stroke="currentColor" 579 viewBox="0 0 24 24" 580 > 581 <path 582 strokeLinecap="round" 583 strokeLinejoin="round" 584 strokeWidth={2} 585 d="M6 18L18 6M6 6l12 12" 586 /> 587 </svg> 588 </button> 589 </div> 590 591 {/* Star Rating */} 592 <div className="mb-6"> 593 <label className="block text-sm font-medium mb-3"> 594 Rating <span className="text-red-400">*</span> 595 </label> 596 <div className="flex gap-2"> 597 {[1, 2, 3, 4, 5].map((star) => ( 598 <button 599 key={star} 600 onClick={() => setReviewRating(star)} 601 onMouseEnter={() => setHoverRating(star)} 602 onMouseLeave={() => setHoverRating(0)} 603 className="transition-transform hover:scale-110" 604 > 605 <svg 606 className={`w-10 h-10 ${ 607 star <= (hoverRating || reviewRating) 608 ? "text-yellow-400" 609 : "text-gray-600" 610 }`} 611 fill="currentColor" 612 viewBox="0 0 20 20" 613 > 614 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 615 </svg> 616 </button> 617 ))} 618 </div> 619 </div> 620 621 {/* Comment */} 622 <div className="mb-6"> 623 <label className="block text-sm font-medium mb-2"> 624 Comment <span className="text-gray-500">(optional)</span> 625 </label> 626 <textarea 627 value={reviewComment} 628 onChange={(e) => setReviewComment(e.target.value)} 629 placeholder="Share your thoughts about this song..." 630 rows={4} 631 className="w-full bg-[#0f0f1e] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] transition-colors resize-none" 632 /> 633 </div> 634 635 {/* Actions */} 636 <div className="flex gap-3 justify-end"> 637 <button 638 onClick={() => { 639 setShowReviewModal(false); 640 setReviewRating(0); 641 setReviewComment(""); 642 setHoverRating(0); 643 }} 644 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors" 645 > 646 Cancel 647 </button> 648 <button 649 onClick={handleSubmitReview} 650 disabled={reviewRating === 0} 651 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-[#1db954] hover:bg-[#1ed760] text-black transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 652 > 653 Submit 654 </button> 655 </div> 656 </div> 657 </div> 658 )} 659 </div> 660 ); 43 const { id } = useParams<{ id: string }>(); 44 const { user } = useAuth(); 45 const { play, currentSong } = usePlayer(); 46 const [song, setSong] = useState<SongDetailType | null>(null); 47 const [loading, setLoading] = useState(true); 48 const [error, setError] = useState<string | null>(null); 49 const [showReviewModal, setShowReviewModal] = useState(false); 50 const [reviewRating, setReviewRating] = useState(0); 51 const [reviewComment, setReviewComment] = useState(""); 52 const [hoverRating, setHoverRating] = useState(0); 53 const [showPlaylistDropdown, setShowPlaylistDropdown] = useState(false); 54 console.log(user); 55 useEffect(() => { 56 const fetchSong = async () => { 57 try { 58 setLoading(true); 59 const response = await axiosInstance.get(`/songs/${id}/details`); 60 setSong(response.data); 61 } catch (err) { 62 console.error("Error fetching song details:", err); 63 setError("Failed to load song details."); 64 } finally { 65 setLoading(false); 66 } 67 }; 68 if (id) fetchSong(); 69 }, [id]); 70 71 const handleSubmitReview = async () => { 72 if (reviewRating === 0) { 73 // todo: replace with toast 74 alert("Please select a rating"); 75 return; 76 } 77 try { 78 await axiosInstance.post(`reviews/${song?.id}`, { 79 grade: reviewRating, 80 comment: reviewComment, 81 }); 82 const response = await axiosInstance.get(`/songs/${id}/details`); 83 setSong(response.data); 84 } catch (err) { 85 // todo: replace with toast 86 console.error("Error submitting review:", err); 87 } finally { 88 setShowReviewModal(false); 89 setReviewRating(0); 90 setReviewComment(""); 91 setHoverRating(0); 92 } 93 }; 94 95 const deleteReview = async () => { 96 try { 97 await axiosInstance.delete(`reviews/${song?.id}`); 98 const response = await axiosInstance.get(`/songs/${id}/details`); 99 setSong(response.data); 100 } catch (err) { 101 toast.error("Failed to delete review"); 102 console.error("Error deleting review:", err); 103 } 104 }; 105 106 const toggleLike = async () => { 107 if (!song) return; 108 try { 109 await axiosInstance.post(`/musical-entity/${song.id}/like`); 110 setSong((prev) => 111 prev 112 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser } 113 : prev, 114 ); 115 } catch (err) { 116 toast.error("Failed to toggle like"); 117 console.error("Error toggling like:", err); 118 } 119 }; 120 121 const handleCreateNewPlaylist = () => { 122 console.log(`Creating new playlist for song ${song?.id}`); 123 // TODO: actual playlist creation 124 setShowPlaylistDropdown(false); 125 }; 126 127 if (loading) { 128 return ( 129 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 130 <div className="flex flex-col items-center gap-4"> 131 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 132 <p className="text-gray-400 text-lg">Loading song…</p> 133 </div> 134 </div> 135 ); 136 } 137 138 if (error || !song) { 139 return ( 140 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 141 <div className="text-center"> 142 <p className="text-red-400 text-xl mb-4"> 143 {error ?? "Song not found"} 144 </p> 145 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 146 ← Back to Home 147 </Link> 148 </div> 149 </div> 150 ); 151 } 152 153 const otherContributors = song.contributions.filter( 154 (c) => c.artistName !== song.releasedBy, 155 ); 156 157 const avgRating = 158 song.reviews.length > 0 159 ? ( 160 song.reviews.reduce((sum, r) => sum + r.grade, 0) / 161 song.reviews.length 162 ).toFixed(1) 163 : null; 164 165 return ( 166 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 167 <div className="max-w-5xl mx-auto px-6 py-10"> 168 {/* Back link */} 169 <Link 170 to="/" 171 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 172 > 173 <svg 174 className="w-4 h-4" 175 fill="none" 176 stroke="currentColor" 177 viewBox="0 0 24 24" 178 > 179 <path 180 strokeLinecap="round" 181 strokeLinejoin="round" 182 strokeWidth={2} 183 d="M15 19l-7-7 7-7" 184 /> 185 </svg> 186 Back to Home 187 </Link> 188 189 {/* Hero section */} 190 <div className="flex flex-col md:flex-row gap-8 mb-10"> 191 {/* Cover art */} 192 <div className="w-full md:w-72 shrink-0"> 193 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 194 <img 195 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 196 alt={song.title} 197 className="absolute inset-0 w-full h-full object-cover" 198 onError={(e) => { 199 (e.target as HTMLImageElement).src = "/favicon.png"; 200 }} 201 /> 202 </div> 203 </div> 204 205 {/* Song info */} 206 <div className="flex flex-col justify-end gap-3 min-w-0"> 207 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 208 {song.genre} • Song 209 </span> 210 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 211 {song.title} 212 </h1> 213 214 {/* Main artist */} 215 <p className="text-xl text-gray-300 font-semibold"> 216 {song.releasedBy} 217 </p> 218 219 {/* Album */} 220 {song.album && ( 221 <p className="text-sm text-gray-500"> 222 Album: <span className="text-gray-300">{song.album}</span> 223 </p> 224 )} 225 226 {/* Rating summary */} 227 {avgRating && ( 228 <div className="flex items-center gap-2 mt-1"> 229 {renderStars(Math.round(Number(avgRating)))} 230 <span className="text-sm text-gray-400"> 231 {avgRating} · {song.reviews.length}{" "} 232 {song.reviews.length === 1 ? "review" : "reviews"} 233 </span> 234 </div> 235 )} 236 237 {/* Action buttons */} 238 <div className="flex items-center gap-3 mt-4"> 239 {/* Play button */} 240 {song.link && ( 241 <button 242 onClick={() => 243 play({ 244 id: song.id, 245 title: song.title, 246 artist: song.releasedBy, 247 cover: song.cover, 248 embedUrl: toEmbedUrl(song.link!), 249 }) 250 } 251 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 252 currentSong?.id === song.id 253 ? "bg-white text-[#1db954]" 254 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 255 }`} 256 > 257 {currentSong?.id === song.id ? ( 258 <> 259 <svg 260 className="w-5 h-5" 261 fill="currentColor" 262 viewBox="0 0 24 24" 263 > 264 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 265 </svg> 266 Now Playing 267 </> 268 ) : ( 269 <> 270 <svg 271 className="w-5 h-5" 272 fill="currentColor" 273 viewBox="0 0 24 24" 274 > 275 <path d="M8 5v14l11-7z" /> 276 </svg> 277 Play Song 278 </> 279 )} 280 </button> 281 )} 282 283 {user && ( 284 <> 285 <button 286 onClick={toggleLike} 287 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 288 song.isLikedByCurrentUser 289 ? "bg-[#1db954] text-black" 290 : "bg-white/10 text-white hover:bg-white/20" 291 }`} 292 > 293 {song.isLikedByCurrentUser ? ( 294 <svg 295 className="w-5 h-5" 296 fill="currentColor" 297 viewBox="0 0 24 24" 298 > 299 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> 300 </svg> 301 ) : ( 302 <svg 303 className="w-5 h-5" 304 fill="none" 305 stroke="currentColor" 306 viewBox="0 0 24 24" 307 > 308 <path 309 strokeLinecap="round" 310 strokeLinejoin="round" 311 strokeWidth={2} 312 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 313 /> 314 </svg> 315 )} 316 {song.isLikedByCurrentUser ? "Liked" : "Like"} 317 </button> 318 319 {/* Add to Playlist button */} 320 <div className="relative"> 321 <button 322 onClick={() => setShowPlaylistDropdown((prev) => !prev)} 323 className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold bg-white/10 text-white hover:bg-white/20 transition-colors cursor-pointer" 324 > 325 <svg 326 className="w-5 h-5" 327 fill="none" 328 stroke="currentColor" 329 viewBox="0 0 24 24" 330 > 331 <path 332 strokeLinecap="round" 333 strokeLinejoin="round" 334 strokeWidth={2} 335 d="M12 4v16m8-8H4" 336 /> 337 </svg> 338 Add to Playlist 339 </button> 340 341 <PlaylistDropdown 342 songId={song.id} 343 isOpen={showPlaylistDropdown} 344 onClose={() => setShowPlaylistDropdown(false)} 345 onCreateNewPlaylist={handleCreateNewPlaylist} 346 direction="below" 347 /> 348 </div> 349 </> 350 )} 351 </div> 352 </div> 353 </div> 354 355 {/* Credits / Contributions */} 356 {song.contributions.length > 0 && ( 357 <section className="mb-10"> 358 <h2 className="text-2xl font-bold mb-4">Credits</h2> 359 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5"> 360 {/* Main artist first */} 361 {song.contributions 362 .filter((c) => c.artistName === song.releasedBy) 363 .map((c, i) => ( 364 <div 365 key={`main-${i}`} 366 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 367 > 368 <div className="flex items-center gap-3"> 369 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm"> 370 {c.artistName.charAt(0).toUpperCase()} 371 </div> 372 <div> 373 <p className="text-white font-semibold text-lg"> 374 {c.artistName} 375 </p> 376 </div> 377 </div> 378 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full"> 379 {formatRole(c.role)} 380 </span> 381 </div> 382 ))} 383 384 {/* Other contributors */} 385 {otherContributors.map((c, i) => ( 386 <div 387 key={`contrib-${i}`} 388 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 389 > 390 <div className="flex items-center gap-3"> 391 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm"> 392 {c.artistName.charAt(0).toUpperCase()} 393 </div> 394 <p className="text-gray-300 font-medium">{c.artistName}</p> 395 </div> 396 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full"> 397 {formatRole(c.role)} 398 </span> 399 </div> 400 ))} 401 </div> 402 </section> 403 )} 404 405 {/* Reviews */} 406 <section> 407 <div className="flex items-center justify-between mb-4"> 408 <h2 className="text-2xl font-bold"> 409 Reviews 410 {song.reviews.length > 0 && ( 411 <span className="text-base font-normal text-gray-500 ml-2"> 412 ({song.reviews.length}) 413 </span> 414 )} 415 </h2> 416 <button 417 onClick={() => setShowReviewModal(true)} 418 className="flex items-center gap-2 bg-[#1db954] hover:bg-[#1ed760] text-black text-sm font-semibold px-4 py-2 rounded-full transition-colors" 419 > 420 <svg 421 className="w-4 h-4" 422 fill="none" 423 stroke="currentColor" 424 viewBox="0 0 24 24" 425 > 426 <path 427 strokeLinecap="round" 428 strokeLinejoin="round" 429 strokeWidth={2} 430 d="M12 4v16m8-8H4" 431 /> 432 </svg> 433 Add Review 434 </button> 435 </div> 436 437 {song.reviews.length === 0 ? ( 438 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center"> 439 <p className="text-gray-500 text-lg">No reviews yet.</p> 440 <p className="text-gray-600 text-sm mt-1"> 441 Be the first to share your thoughts! 442 </p> 443 </div> 444 ) : ( 445 <div className="space-y-4"> 446 {song.reviews.map((review) => ( 447 <div 448 key={`${review.id.listenerId}-${review.id.musicalEntityId}`} 449 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors" 450 > 451 <div className="flex items-start justify-between mb-2"> 452 <div className="flex items-center gap-3"> 453 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center"> 454 <span className="text-blue-400 font-semibold text-sm"> 455 {review.author.charAt(0).toUpperCase()} 456 </span> 457 </div> 458 <div> 459 <p className="text-white font-medium"> 460 {review.author} 461 </p> 462 {renderStars(review.grade)} 463 </div> 464 </div> 465 {user?.username === review.authorUsername && ( 466 <button 467 onClick={deleteReview} 468 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10" 469 title="Delete review" 470 > 471 <svg 472 className="w-5 h-5" 473 fill="none" 474 stroke="currentColor" 475 viewBox="0 0 24 24" 476 > 477 <path 478 strokeLinecap="round" 479 strokeLinejoin="round" 480 strokeWidth={2} 481 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 482 /> 483 </svg> 484 </button> 485 )} 486 </div> 487 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12"> 488 {review.comment} 489 </p> 490 </div> 491 ))} 492 </div> 493 )} 494 </section> 495 </div> 496 497 {/* Review Modal */} 498 {showReviewModal && ( 499 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> 500 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl"> 501 <div className="flex items-center justify-between mb-6"> 502 <h3 className="text-2xl font-bold">Add Review</h3> 503 <button 504 onClick={() => { 505 setShowReviewModal(false); 506 setReviewRating(0); 507 setReviewComment(""); 508 setHoverRating(0); 509 }} 510 className="text-gray-400 hover:text-white transition-colors" 511 > 512 <svg 513 className="w-6 h-6" 514 fill="none" 515 stroke="currentColor" 516 viewBox="0 0 24 24" 517 > 518 <path 519 strokeLinecap="round" 520 strokeLinejoin="round" 521 strokeWidth={2} 522 d="M6 18L18 6M6 6l12 12" 523 /> 524 </svg> 525 </button> 526 </div> 527 528 {/* Star Rating */} 529 <div className="mb-6"> 530 <label className="block text-sm font-medium mb-3"> 531 Rating <span className="text-red-400">*</span> 532 </label> 533 <div className="flex gap-2"> 534 {[1, 2, 3, 4, 5].map((star) => ( 535 <button 536 key={star} 537 onClick={() => setReviewRating(star)} 538 onMouseEnter={() => setHoverRating(star)} 539 onMouseLeave={() => setHoverRating(0)} 540 className="transition-transform hover:scale-110" 541 > 542 <svg 543 className={`w-10 h-10 ${ 544 star <= (hoverRating || reviewRating) 545 ? "text-yellow-400" 546 : "text-gray-600" 547 }`} 548 fill="currentColor" 549 viewBox="0 0 20 20" 550 > 551 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 552 </svg> 553 </button> 554 ))} 555 </div> 556 </div> 557 558 {/* Comment */} 559 <div className="mb-6"> 560 <label className="block text-sm font-medium mb-2"> 561 Comment <span className="text-gray-500">(optional)</span> 562 </label> 563 <textarea 564 value={reviewComment} 565 onChange={(e) => setReviewComment(e.target.value)} 566 placeholder="Share your thoughts about this song..." 567 rows={4} 568 className="w-full bg-[#0f0f1e] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] transition-colors resize-none" 569 /> 570 </div> 571 572 {/* Actions */} 573 <div className="flex gap-3 justify-end"> 574 <button 575 onClick={() => { 576 setShowReviewModal(false); 577 setReviewRating(0); 578 setReviewComment(""); 579 setHoverRating(0); 580 }} 581 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors" 582 > 583 Cancel 584 </button> 585 <button 586 onClick={handleSubmitReview} 587 disabled={reviewRating === 0} 588 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-[#1db954] hover:bg-[#1ed760] text-black transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 589 > 590 Submit 591 </button> 592 </div> 593 </div> 594 </div> 595 )} 596 </div> 597 ); 661 598 }; 662 599 -
frontend/src/pages/UserDetail.tsx
r85512ff rce45c7a 14 14 Playlist, 15 15 } from "../utils/types"; 16 import { useCreatedPlaylists } from "../context/playlistContext";17 16 18 17 interface FollowStatus { … … 38 37 const { username: usernameParam } = useParams(); 39 38 const { user: currentUser } = useAuth(); 40 const { createdPlaylists: currentUserCreatedPlaylists } = 41 useCreatedPlaylists(); 39 42 40 const navigate = useNavigate(); 43 41 const [user, setUser] = useState<UserProfile | null>(null);
Note:
See TracChangeset
for help on using the changeset viewer.
