Changeset 947fcaa for frontend/src/pages/SongDetail.tsx
- Timestamp:
- 02/21/26 19:19:51 (5 months ago)
- Branches:
- main
- Parents:
- 765e166
- File:
-
- 1 edited
-
frontend/src/pages/SongDetail.tsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/SongDetail.tsx
r765e166 r947fcaa 10 10 11 11 const ROLE_LABELS: Record<string, string> = { 12 MAIN_VOCAL: "Main Vocal",13 FEATURED: "Featured",14 PRODUCER: "Producer",15 SONGWRITER: "Songwriter",16 COMPOSER: "Composer",17 MIXER: "Mixer",18 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", 19 19 }; 20 20 21 21 const formatRole = (role: string): string => { 22 return ROLE_LABELS[role] || role.replace(/_/g, " ");22 return ROLE_LABELS[role] || role.replace(/_/g, " "); 23 23 }; 24 24 25 25 const renderStars = (grade: number) => { 26 return (27 <div className="flex gap-0.5">28 {[1, 2, 3, 4, 5].map((star) => (29 <svg30 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 );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 ); 40 40 }; 41 41 42 42 const SongDetail = () => { 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 toast74 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 toast86 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 prev112 ? { ...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 if (loading) {122 return (123 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">124 <div className="flex flex-col items-center gap-4">125 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />126 <p className="text-gray-400 text-lg">Loading song…</p>127 </div>128 </div>129 );130 }131 132 if (error || !song) {133 return (134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">135 <div className="text-center">136 <p className="text-red-400 text-xl mb-4">137 {error ?? "Song not found"}138 </p>139 <Link to="/" className="text-[#1db954] hover:underline text-sm">140 ← Back to Home141 </Link>142 </div>143 </div>144 );145 }146 147 const otherContributors = song.contributions.filter(148 (c) => c.artistName !== song.releasedBy,149 );150 151 const avgRating =152 song.reviews.length > 0153 ? (154 song.reviews.reduce((sum, r) => sum + r.grade, 0) /155 song.reviews.length156 ).toFixed(1)157 : null;158 159 return (160 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">161 <div className="max-w-5xl mx-auto px-6 py-10">162 {/* Back link */}163 <Link164 to="/"165 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"166 >167 <svg168 className="w-4 h-4"169 fill="none"170 stroke="currentColor"171 viewBox="0 0 24 24"172 >173 <path174 strokeLinecap="round"175 strokeLinejoin="round"176 strokeWidth={2}177 d="M15 19l-7-7 7-7"178 />179 </svg>180 Back to Home181 </Link>182 183 {/* Hero section */}184 <div className="flex flex-col md:flex-row gap-8 mb-10">185 {/* Cover art */}186 <div className="w-full md:w-72 shrink-0">187 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">188 <img189 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}190 alt={song.title}191 className="absolute inset-0 w-full h-full object-cover"192 onError={(e) => {193 (e.target as HTMLImageElement).src = "/favicon.png";194 }}195 />196 </div>197 </div>198 199 {/* Song info */}200 <div className="flex flex-col justify-end gap-3 min-w-0">201 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">202 {song.genre} • Song203 </span>204 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">205 {song.title}206 </h1>207 208 {/* Main artist */}209 <p className="text-xl text-gray-300 font-semibold">210 {song.releasedBy}211 </p>212 213 {/* Album */}214 {song.album && (215 <p className="text-sm text-gray-500">216 Album: <span className="text-gray-300">{song.album}</span>217 </p>218 )}219 220 {/* Rating summary */}221 {avgRating && (222 <div className="flex items-center gap-2 mt-1">223 {renderStars(Math.round(Number(avgRating)))}224 <span className="text-sm text-gray-400">225 {avgRating} · {song.reviews.length}{" "}226 {song.reviews.length === 1 ? "review" : "reviews"}227 </span>228 </div>229 )}230 231 {/* Action buttons */}232 <div className="flex items-center gap-3 mt-4">233 {/* Play button */}234 {song.link && (235 <button236 onClick={() =>237 play({238 id: song.id,239 title: song.title,240 artist: song.releasedBy,241 cover: song.cover,242 embedUrl: toEmbedUrl(song.link!),243 })244 }245 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${246 currentSong?.id === song.id247 ? "bg-white text-[#1db954]"248 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105"249 }`}250 >251 {currentSong?.id === song.id ? (252 <>253 <svg254 className="w-5 h-5"255 fill="currentColor"256 viewBox="0 0 24 24"257 >258 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />259 </svg>260 Now Playing261 </>262 ) : (263 <>264 <svg265 className="w-5 h-5"266 fill="currentColor"267 viewBox="0 0 24 24"268 >269 <path d="M8 5v14l11-7z" />270 </svg>271 Play Song272 </>273 )}274 </button>275 )}276 277 {user && (278 <>279 <button280 onClick={toggleLike}281 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${282 song.isLikedByCurrentUser283 ? "bg-[#1db954] text-black"284 : "bg-white/10 text-white hover:bg-white/20"285 }`}286 >287 {song.isLikedByCurrentUser ? (288 <svg289 className="w-5 h-5"290 fill="currentColor"291 viewBox="0 0 24 24"292 >293 <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" />294 </svg>295 ) : (296 <svg297 className="w-5 h-5"298 fill="none"299 stroke="currentColor"300 viewBox="0 0 24 24"301 >302 <path303 strokeLinecap="round"304 strokeLinejoin="round"305 strokeWidth={2}306 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"307 />308 </svg>309 )}310 {song.isLikedByCurrentUser ? "Liked" : "Like"}311 </button>312 313 {/* Add to Playlist button */}314 <div className="relative">315 <button316 onClick={() => setShowPlaylistDropdown((prev) => !prev)}317 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"318 >319 <svg320 className="w-5 h-5"321 fill="none"322 stroke="currentColor"323 viewBox="0 0 24 24"324 >325 <path326 strokeLinecap="round"327 strokeLinejoin="round"328 strokeWidth={2}329 d="M12 4v16m8-8H4"330 />331 </svg>332 Add to Playlist333 </button>334 335 <PlaylistDropdown336 songId={song.id}337 isOpen={showPlaylistDropdown}338 onClose={() => setShowPlaylistDropdown(false)}339 direction="below"340 />341 </div>342 </>343 )}344 </div>345 </div>346 </div>347 348 {/* Credits / Contributions */}349 {song.contributions.length > 0 && (350 <section className="mb-10">351 <h2 className="text-2xl font-bold mb-4">Credits</h2>352 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">353 {/* Main artist first */}354 {song.contributions355 .filter((c) => c.artistName === song.releasedBy)356 .map((c, i) => (357 <div358 key={`main-${i}`}359 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"360 >361 <div className="flex items-center gap-3">362 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">363 {c.artistName.charAt(0).toUpperCase()}364 </div>365 <div>366 <p className="text-white font-semibold text-lg">367 {c.artistName}368 </p>369 </div>370 </div>371 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">372 {formatRole(c.role)}373 </span>374 </div>375 ))}376 377 {/* Other contributors */}378 {otherContributors.map((c, i) => (379 <div380 key={`contrib-${i}`}381 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"382 >383 <div className="flex items-center gap-3">384 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">385 {c.artistName.charAt(0).toUpperCase()}386 </div>387 <p className="text-gray-300 font-medium">{c.artistName}</p>388 </div>389 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">390 {formatRole(c.role)}391 </span>392 </div>393 ))}394 </div>395 </section>396 )}397 398 {/* Reviews */}399 <section>400 <div className="flex items-center justify-between mb-4">401 <h2 className="text-2xl font-bold">402 Reviews403 {song.reviews.length > 0 && (404 <span className="text-base font-normal text-gray-500 ml-2">405 ({song.reviews.length})406 </span>407 )}408 </h2>409 {user != null && (410 <button411 onClick={() => setShowReviewModal(true)}412 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"413 >414 <svg415 className="w-4 h-4"416 fill="none"417 stroke="currentColor"418 viewBox="0 0 24 24"419 >420 <path421 strokeLinecap="round"422 strokeLinejoin="round"423 strokeWidth={2}424 d="M12 4v16m8-8H4"425 />426 </svg>427 Add Review428 </button>429 )}430 </div>431 432 {song.reviews.length === 0 ? (433 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">434 <p className="text-gray-500 text-lg">No reviews yet.</p>435 <p className="text-gray-600 text-sm mt-1">436 Be the first to share your thoughts!437 </p>438 </div>439 ) : (440 <div className="space-y-4">441 {song.reviews.map((review) => (442 <div443 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}444 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"445 >446 <div className="flex items-start justify-between mb-2">447 <div className="flex items-center gap-3">448 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">449 <span className="text-blue-400 font-semibold text-sm">450 {review.author.charAt(0).toUpperCase()}451 </span>452 </div>453 <div>454 <p className="text-white font-medium">455 {review.author}456 </p>457 {renderStars(review.grade)}458 </div>459 </div>460 {user?.username === review.authorUsername && (461 <button462 onClick={deleteReview}463 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10"464 title="Delete review"465 >466 <svg467 className="w-5 h-5"468 fill="none"469 stroke="currentColor"470 viewBox="0 0 24 24"471 >472 <path473 strokeLinecap="round"474 strokeLinejoin="round"475 strokeWidth={2}476 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"477 />478 </svg>479 </button>480 )}481 </div>482 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">483 {review.comment}484 </p>485 </div>486 ))}487 </div>488 )}489 </section>490 </div>491 492 {/* Review Modal */}493 {showReviewModal && (494 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">495 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl">496 <div className="flex items-center justify-between mb-6">497 <h3 className="text-2xl font-bold">Add Review</h3>498 <button499 onClick={() => {500 setShowReviewModal(false);501 setReviewRating(0);502 setReviewComment("");503 setHoverRating(0);504 }}505 className="text-gray-400 hover:text-white transition-colors"506 >507 <svg508 className="w-6 h-6"509 fill="none"510 stroke="currentColor"511 viewBox="0 0 24 24"512 >513 <path514 strokeLinecap="round"515 strokeLinejoin="round"516 strokeWidth={2}517 d="M6 18L18 6M6 6l12 12"518 />519 </svg>520 </button>521 </div>522 523 {/* Star Rating */}524 <div className="mb-6">525 <label className="block text-sm font-medium mb-3">526 Rating <span className="text-red-400">*</span>527 </label>528 <div className="flex gap-2">529 {[1, 2, 3, 4, 5].map((star) => (530 <button531 key={star}532 onClick={() => setReviewRating(star)}533 onMouseEnter={() => setHoverRating(star)}534 onMouseLeave={() => setHoverRating(0)}535 className="transition-transform hover:scale-110"536 >537 <svg538 className={`w-10 h-10 ${539 star <= (hoverRating || reviewRating)540 ? "text-yellow-400"541 : "text-gray-600"542 }`}543 fill="currentColor"544 viewBox="0 0 20 20"545 >546 <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" />547 </svg>548 </button>549 ))}550 </div>551 </div>552 553 {/* Comment */}554 <div className="mb-6">555 <label className="block text-sm font-medium mb-2">556 Comment <span className="text-gray-500">(optional)</span>557 </label>558 <textarea559 value={reviewComment}560 onChange={(e) => setReviewComment(e.target.value)}561 placeholder="Share your thoughts about this song..."562 rows={4}563 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"564 />565 </div>566 567 {/* Actions */}568 <div className="flex gap-3 justify-end">569 <button570 onClick={() => {571 setShowReviewModal(false);572 setReviewRating(0);573 setReviewComment("");574 setHoverRating(0);575 }}576 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors"577 >578 Cancel579 </button>580 <button581 onClick={handleSubmitReview}582 disabled={reviewRating === 0}583 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"584 >585 Submit586 </button>587 </div>588 </div>589 </div>590 )}591 </div>592 );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 if (loading) { 122 return ( 123 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 124 <div className="flex flex-col items-center gap-4"> 125 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 126 <p className="text-gray-400 text-lg">Loading song…</p> 127 </div> 128 </div> 129 ); 130 } 131 132 if (error || !song) { 133 return ( 134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 135 <div className="text-center"> 136 <p className="text-red-400 text-xl mb-4"> 137 {error ?? "Song not found"} 138 </p> 139 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 140 ← Back to Home 141 </Link> 142 </div> 143 </div> 144 ); 145 } 146 147 const otherContributors = song.contributions.filter( 148 (c) => c.artistName !== song.releasedBy, 149 ); 150 151 const avgRating = 152 song.reviews.length > 0 153 ? ( 154 song.reviews.reduce((sum, r) => sum + r.grade, 0) / 155 song.reviews.length 156 ).toFixed(1) 157 : null; 158 159 return ( 160 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 161 <div className="max-w-5xl mx-auto px-6 py-10"> 162 {/* Back link */} 163 <Link 164 to="/" 165 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 166 > 167 <svg 168 className="w-4 h-4" 169 fill="none" 170 stroke="currentColor" 171 viewBox="0 0 24 24" 172 > 173 <path 174 strokeLinecap="round" 175 strokeLinejoin="round" 176 strokeWidth={2} 177 d="M15 19l-7-7 7-7" 178 /> 179 </svg> 180 Back to Home 181 </Link> 182 183 {/* Hero section */} 184 <div className="flex flex-col md:flex-row gap-8 mb-10"> 185 {/* Cover art */} 186 <div className="w-full md:w-72 shrink-0"> 187 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 188 <img 189 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 190 alt={song.title} 191 className="absolute inset-0 w-full h-full object-cover" 192 onError={(e) => { 193 (e.target as HTMLImageElement).src = "/favicon.png"; 194 }} 195 /> 196 </div> 197 </div> 198 199 {/* Song info */} 200 <div className="flex flex-col justify-end gap-3 min-w-0"> 201 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 202 {song.genre} • Song 203 </span> 204 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 205 {song.title} 206 </h1> 207 208 {/* Main artist */} 209 <p className="text-xl text-gray-300 font-semibold"> 210 {song.releasedBy} 211 </p> 212 213 {/* Album */} 214 {song.album && ( 215 <p className="text-sm text-gray-500"> 216 Album: <span className="text-gray-300">{song.album}</span> 217 </p> 218 )} 219 220 {/* Rating summary */} 221 {avgRating && ( 222 <div className="flex items-center gap-2 mt-1"> 223 {renderStars(Math.round(Number(avgRating)))} 224 <span className="text-sm text-gray-400"> 225 {avgRating} · {song.reviews.length}{" "} 226 {song.reviews.length === 1 ? "review" : "reviews"} 227 </span> 228 </div> 229 )} 230 231 {/* Action buttons */} 232 <div className="flex items-center gap-3 mt-4"> 233 {/* Play button */} 234 {song.link && ( 235 <button 236 onClick={() => 237 play({ 238 id: song.id, 239 title: song.title, 240 artist: song.releasedBy, 241 cover: song.cover, 242 embedUrl: toEmbedUrl(song.link!), 243 }) 244 } 245 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 246 currentSong?.id === song.id 247 ? "bg-white text-[#1db954]" 248 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 249 }`} 250 > 251 {currentSong?.id === song.id ? ( 252 <> 253 <svg 254 className="w-5 h-5" 255 fill="currentColor" 256 viewBox="0 0 24 24" 257 > 258 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 259 </svg> 260 Now Playing 261 </> 262 ) : ( 263 <> 264 <svg 265 className="w-5 h-5" 266 fill="currentColor" 267 viewBox="0 0 24 24" 268 > 269 <path d="M8 5v14l11-7z" /> 270 </svg> 271 Play Song 272 </> 273 )} 274 </button> 275 )} 276 277 {user && ( 278 <> 279 <button 280 onClick={toggleLike} 281 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 282 song.isLikedByCurrentUser 283 ? "bg-[#1db954] text-black" 284 : "bg-white/10 text-white hover:bg-white/20" 285 }`} 286 > 287 {song.isLikedByCurrentUser ? ( 288 <svg 289 className="w-5 h-5" 290 fill="currentColor" 291 viewBox="0 0 24 24" 292 > 293 <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" /> 294 </svg> 295 ) : ( 296 <svg 297 className="w-5 h-5" 298 fill="none" 299 stroke="currentColor" 300 viewBox="0 0 24 24" 301 > 302 <path 303 strokeLinecap="round" 304 strokeLinejoin="round" 305 strokeWidth={2} 306 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" 307 /> 308 </svg> 309 )} 310 {song.isLikedByCurrentUser ? "Liked" : "Like"} 311 </button> 312 313 {/* Add to Playlist button */} 314 <div className="relative"> 315 <button 316 onClick={() => setShowPlaylistDropdown((prev) => !prev)} 317 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" 318 > 319 <svg 320 className="w-5 h-5" 321 fill="none" 322 stroke="currentColor" 323 viewBox="0 0 24 24" 324 > 325 <path 326 strokeLinecap="round" 327 strokeLinejoin="round" 328 strokeWidth={2} 329 d="M12 4v16m8-8H4" 330 /> 331 </svg> 332 Add to Playlist 333 </button> 334 335 <PlaylistDropdown 336 songId={song.id} 337 isOpen={showPlaylistDropdown} 338 onClose={() => setShowPlaylistDropdown(false)} 339 direction="below" 340 /> 341 </div> 342 </> 343 )} 344 </div> 345 </div> 346 </div> 347 348 {/* Credits / Contributions */} 349 {song.contributions.length > 0 && ( 350 <section className="mb-10"> 351 <h2 className="text-2xl font-bold mb-4">Credits</h2> 352 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5"> 353 {/* Main artist first */} 354 {song.contributions 355 .filter((c) => c.artistName === song.releasedBy) 356 .map((c, i) => ( 357 <div 358 key={`main-${i}`} 359 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 360 > 361 <div className="flex items-center gap-3"> 362 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm"> 363 {c.artistName.charAt(0).toUpperCase()} 364 </div> 365 <div> 366 <p className="text-white font-semibold text-lg"> 367 {c.artistName} 368 </p> 369 </div> 370 </div> 371 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full"> 372 {formatRole(c.role)} 373 </span> 374 </div> 375 ))} 376 377 {/* Other contributors */} 378 {otherContributors.map((c, i) => ( 379 <div 380 key={`contrib-${i}`} 381 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 382 > 383 <div className="flex items-center gap-3"> 384 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm"> 385 {c.artistName.charAt(0).toUpperCase()} 386 </div> 387 <p className="text-gray-300 font-medium">{c.artistName}</p> 388 </div> 389 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full"> 390 {formatRole(c.role)} 391 </span> 392 </div> 393 ))} 394 </div> 395 </section> 396 )} 397 398 {/* Reviews */} 399 <section> 400 <div className="flex items-center justify-between mb-4"> 401 <h2 className="text-2xl font-bold"> 402 Reviews 403 {song.reviews.length > 0 && ( 404 <span className="text-base font-normal text-gray-500 ml-2"> 405 ({song.reviews.length}) 406 </span> 407 )} 408 </h2> 409 {user != null && ( 410 <button 411 onClick={() => setShowReviewModal(true)} 412 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 cursor-pointer" 413 > 414 <svg 415 className="w-4 h-4" 416 fill="none" 417 stroke="currentColor" 418 viewBox="0 0 24 24" 419 > 420 <path 421 strokeLinecap="round" 422 strokeLinejoin="round" 423 strokeWidth={2} 424 d="M12 4v16m8-8H4" 425 /> 426 </svg> 427 Add Review 428 </button> 429 )} 430 </div> 431 432 {song.reviews.length === 0 ? ( 433 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center"> 434 <p className="text-gray-500 text-lg">No reviews yet.</p> 435 <p className="text-gray-600 text-sm mt-1"> 436 Be the first to share your thoughts! 437 </p> 438 </div> 439 ) : ( 440 <div className="space-y-4"> 441 {song.reviews.map((review) => ( 442 <div 443 key={`${review.id.listenerId}-${review.id.musicalEntityId}`} 444 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors" 445 > 446 <div className="flex items-start justify-between mb-2"> 447 <div className="flex items-center gap-3"> 448 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center"> 449 <span className="text-blue-400 font-semibold text-sm"> 450 {review.author.charAt(0).toUpperCase()} 451 </span> 452 </div> 453 <div> 454 <p className="text-white font-medium"> 455 {review.author} 456 </p> 457 {renderStars(review.grade)} 458 </div> 459 </div> 460 {user?.username === review.authorUsername && ( 461 <button 462 onClick={deleteReview} 463 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10 cursor-pointer" 464 title="Delete review" 465 > 466 <svg 467 className="w-5 h-5" 468 fill="none" 469 stroke="currentColor" 470 viewBox="0 0 24 24" 471 > 472 <path 473 strokeLinecap="round" 474 strokeLinejoin="round" 475 strokeWidth={2} 476 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" 477 /> 478 </svg> 479 </button> 480 )} 481 </div> 482 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12"> 483 {review.comment} 484 </p> 485 </div> 486 ))} 487 </div> 488 )} 489 </section> 490 </div> 491 492 {/* Review Modal */} 493 {showReviewModal && ( 494 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> 495 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl"> 496 <div className="flex items-center justify-between mb-6"> 497 <h3 className="text-2xl font-bold">Add Review</h3> 498 <button 499 onClick={() => { 500 setShowReviewModal(false); 501 setReviewRating(0); 502 setReviewComment(""); 503 setHoverRating(0); 504 }} 505 className="text-gray-400 hover:text-white transition-colors cursor-pointer" 506 > 507 <svg 508 className="w-6 h-6" 509 fill="none" 510 stroke="currentColor" 511 viewBox="0 0 24 24" 512 > 513 <path 514 strokeLinecap="round" 515 strokeLinejoin="round" 516 strokeWidth={2} 517 d="M6 18L18 6M6 6l12 12" 518 /> 519 </svg> 520 </button> 521 </div> 522 523 {/* Star Rating */} 524 <div className="mb-6"> 525 <label className="block text-sm font-medium mb-3"> 526 Rating <span className="text-red-400">*</span> 527 </label> 528 <div className="flex gap-2"> 529 {[1, 2, 3, 4, 5].map((star) => ( 530 <button 531 key={star} 532 onClick={() => setReviewRating(star)} 533 onMouseEnter={() => setHoverRating(star)} 534 onMouseLeave={() => setHoverRating(0)} 535 className="transition-transform hover:scale-110 cursor-pointer" 536 > 537 <svg 538 className={`w-10 h-10 ${ 539 star <= (hoverRating || reviewRating) 540 ? "text-yellow-400" 541 : "text-gray-600" 542 }`} 543 fill="currentColor" 544 viewBox="0 0 20 20" 545 > 546 <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" /> 547 </svg> 548 </button> 549 ))} 550 </div> 551 </div> 552 553 {/* Comment */} 554 <div className="mb-6"> 555 <label className="block text-sm font-medium mb-2"> 556 Comment <span className="text-gray-500">(optional)</span> 557 </label> 558 <textarea 559 value={reviewComment} 560 onChange={(e) => setReviewComment(e.target.value)} 561 placeholder="Share your thoughts about this song..." 562 rows={4} 563 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" 564 /> 565 </div> 566 567 {/* Actions */} 568 <div className="flex gap-3 justify-end"> 569 <button 570 onClick={() => { 571 setShowReviewModal(false); 572 setReviewRating(0); 573 setReviewComment(""); 574 setHoverRating(0); 575 }} 576 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors cursor-pointer" 577 > 578 Cancel 579 </button> 580 <button 581 onClick={handleSubmitReview} 582 disabled={reviewRating === 0} 583 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 cursor-pointer" 584 > 585 Submit 586 </button> 587 </div> 588 </div> 589 </div> 590 )} 591 </div> 592 ); 593 593 }; 594 594
Note:
See TracChangeset
for help on using the changeset viewer.
