source: frontend/src/pages/SongDetail.tsx@ 92db381

main
Last change on this file since 92db381 was 1579b4f, checked in by Filip Gavrilovski <filipgavrilovski28@…>, 5 months ago

refactor artist, collection, listener components to have similar style as other components; refactor some dto-s

  • Property mode set to 100644
File size: 20.8 KB
Line 
1import { useEffect, useRef, useState } from "react";
2import { Link, useParams } from "react-router-dom";
3import { toast } from "react-toastify";
4import axiosInstance from "../api/axiosInstance";
5import { useAuth } from "../context/authContext";
6import { usePlayer } from "../context/playerContext";
7import type { SongDetail as SongDetailType } from "../utils/types";
8import { toEmbedUrl } from "../utils/utils";
9
10const 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",
18};
19
20const formatRole = (role: string): string => {
21 return ROLE_LABELS[role] || role.replace(/_/g, " ");
22};
23
24const renderStars = (grade: number) => {
25 return (
26 <div className="flex gap-0.5">
27 {[1, 2, 3, 4, 5].map((star) => (
28 <svg
29 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 );
39};
40
41const 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 || "/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 );
661};
662
663export default SongDetail;
Note: See TracBrowser for help on using the repository browser.