source: frontend/src/pages/SongDetail.tsx@ 947fcaa

main
Last change on this file since 947fcaa was 947fcaa, checked in by Filip Gavrilovski <filipgavrilovski28@…>, 5 months ago

add cursor pointer to buttons

  • Property mode set to 100644
File size: 18.5 KB
Line 
1import { useEffect, useState } from "react";
2import { Link, useParams } from "react-router-dom";
3import { toast } from "react-toastify";
4import axiosInstance, { baseURL } from "../api/axiosInstance";
5import PlaylistDropdown from "../components/playlist/PlaylistDropdown";
6import { useAuth } from "../context/authContext";
7import { usePlayer } from "../context/playerContext";
8import type { SongDetail as SongDetailType } from "../utils/types";
9import { toEmbedUrl } from "../utils/utils";
10
11const 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",
19};
20
21const formatRole = (role: string): string => {
22 return ROLE_LABELS[role] || role.replace(/_/g, " ");
23};
24
25const renderStars = (grade: number) => {
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};
41
42const 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 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};
594
595export default SongDetail;
Note: See TracBrowser for help on using the repository browser.