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