source: frontend/src/pages/SongDetail.tsx@ 821dc0e

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

add add to playlist button to song detail page

  • Property mode set to 100644
File size: 21.2 KB
RevLine 
[821dc0e]1import { useEffect, useRef, useState } from "react";
[8a47b30]2import { Link, useParams } from "react-router-dom";
3import axiosInstance from "../api/axiosInstance";
4import { useAuth } from "../context/authContext";
[9c1dcc7]5import { usePlayer } from "../context/playerContext";
[8a47b30]6import type { SongDetail as SongDetailType } from "../utils/types";
7
8const ROLE_LABELS: Record<string, string> = {
9 MAIN_VOCAL: "Main Vocal",
10 FEATURED: "Featured",
11 PRODUCER: "Producer",
12 SONGWRITER: "Songwriter",
13 COMPOSER: "Composer",
14 MIXER: "Mixer",
15 ENGINEER: "Engineer",
16};
17
18const formatRole = (role: string): string => {
19 return ROLE_LABELS[role] || role.replace(/_/g, " ");
20};
21
22// convert a regular youtube URL to an embeddable URL
23const toEmbedUrl = (url: string): string => {
24 try {
25 const parsed = new URL(url);
26 // youtube.com/watch?v=ID
27 if (
28 (parsed.hostname === "www.youtube.com" ||
29 parsed.hostname === "youtube.com") &&
30 parsed.searchParams.has("v")
31 ) {
32 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`;
33 }
34 // youtu.be/ID
35 if (parsed.hostname === "youtu.be") {
36 return `https://www.youtube.com/embed${parsed.pathname}`;
37 }
38 // already an embed URL or other provider – return as-is
39 return url;
40 } catch {
41 return url;
42 }
43};
44
45const renderStars = (grade: number) => {
46 return (
47 <div className="flex gap-0.5">
48 {[1, 2, 3, 4, 5].map((star) => (
49 <svg
50 key={star}
51 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`}
52 fill="currentColor"
53 viewBox="0 0 20 20"
54 >
55 <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" />
56 </svg>
57 ))}
58 </div>
59 );
60};
61
62const SongDetail = () => {
63 const { id } = useParams<{ id: string }>();
64 const { user } = useAuth();
[9c1dcc7]65 const { play, currentSong } = usePlayer();
[8a47b30]66 const [song, setSong] = useState<SongDetailType | null>(null);
67 const [loading, setLoading] = useState(true);
68 const [error, setError] = useState<string | null>(null);
[7621d7b]69 const [showReviewModal, setShowReviewModal] = useState(false);
70 const [reviewRating, setReviewRating] = useState(0);
71 const [reviewComment, setReviewComment] = useState("");
72 const [hoverRating, setHoverRating] = useState(0);
[821dc0e]73 const [showPlaylistDropdown, setShowPlaylistDropdown] = useState(false);
74 const playlistDropdownRef = useRef<HTMLDivElement>(null);
[7621d7b]75 console.log(user);
[8a47b30]76 useEffect(() => {
77 const fetchSong = async () => {
78 try {
79 setLoading(true);
80 const response = await axiosInstance.get(`/songs/${id}/details`);
81 setSong(response.data);
82 } catch (err) {
83 console.error("Error fetching song details:", err);
84 setError("Failed to load song details.");
85 } finally {
86 setLoading(false);
87 }
88 };
89 if (id) fetchSong();
90 }, [id]);
91
[821dc0e]92 // handle click outside for playlist dropdown
93 useEffect(() => {
94 const handleClickOutside = (event: MouseEvent) => {
95 if (
96 playlistDropdownRef.current &&
97 !playlistDropdownRef.current.contains(event.target as Node)
98 ) {
99 setShowPlaylistDropdown(false);
100 }
101 };
102
103 document.addEventListener("mousedown", handleClickOutside);
104 return () => {
105 document.removeEventListener("mousedown", handleClickOutside);
106 };
107 }, []);
108
[7621d7b]109 const handleSubmitReview = async () => {
110 if (reviewRating === 0) {
111 // todo: replace with toast
112 alert("Please select a rating");
113 return;
114 }
115 try {
116 await axiosInstance.post(`reviews/${song?.id}`, {
117 grade: reviewRating,
118 comment: reviewComment,
119 });
120 const response = await axiosInstance.get(`/songs/${id}/details`);
121 setSong(response.data);
122 } catch (err) {
123 // todo: replace with toast
124 console.error("Error submitting review:", err);
125 } finally {
126 setShowReviewModal(false);
127 setReviewRating(0);
128 setReviewComment("");
129 setHoverRating(0);
130 }
131 };
132
133 const deleteReview = async () => {
134 try {
135 await axiosInstance.delete(`reviews/${song?.id}`);
136 const response = await axiosInstance.get(`/songs/${id}/details`);
137 setSong(response.data);
138 } catch (err) {
139 console.error("Error deleting review:", err);
140 }
141 };
142
[8a47b30]143 const toggleLike = async () => {
144 if (!song) return;
145 try {
146 await axiosInstance.post(`/musical-entity/${song.id}/like`);
147 setSong((prev) =>
148 prev
149 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }
150 : prev,
151 );
152 } catch (err) {
153 console.error("Error toggling like:", err);
154 }
155 };
156
[821dc0e]157 const handleAddToPlaylist = (playlistName: string) => {
158 console.log(`Adding song ${song?.id} to ${playlistName}`);
159 // TODO: actual API call
160 setShowPlaylistDropdown(false);
161 };
162
163 const handleCreateNewPlaylist = () => {
164 console.log(`Creating new playlist for song ${song?.id}`);
165 // TODO: actual playlist creation
166 setShowPlaylistDropdown(false);
167 };
168
[8a47b30]169 if (loading) {
170 return (
171 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
172 <div className="flex flex-col items-center gap-4">
173 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />
174 <p className="text-gray-400 text-lg">Loading song…</p>
175 </div>
176 </div>
177 );
178 }
179
180 if (error || !song) {
181 return (
182 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
183 <div className="text-center">
184 <p className="text-red-400 text-xl mb-4">
185 {error ?? "Song not found"}
186 </p>
187 <Link to="/" className="text-[#1db954] hover:underline text-sm">
188 ← Back to Home
189 </Link>
190 </div>
191 </div>
192 );
193 }
194
195 const otherContributors = song.contributions.filter(
196 (c) => c.artistName !== song.releasedBy,
197 );
198
199 const avgRating =
200 song.reviews.length > 0
201 ? (
202 song.reviews.reduce((sum, r) => sum + r.grade, 0) /
203 song.reviews.length
204 ).toFixed(1)
205 : null;
206
207 return (
208 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
209 <div className="max-w-5xl mx-auto px-6 py-10">
210 {/* Back link */}
211 <Link
212 to="/"
213 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
214 >
215 <svg
216 className="w-4 h-4"
217 fill="none"
218 stroke="currentColor"
219 viewBox="0 0 24 24"
220 >
221 <path
222 strokeLinecap="round"
223 strokeLinejoin="round"
224 strokeWidth={2}
225 d="M15 19l-7-7 7-7"
226 />
227 </svg>
228 Back to Home
229 </Link>
230
231 {/* Hero section */}
232 <div className="flex flex-col md:flex-row gap-8 mb-10">
233 {/* Cover art */}
234 <div className="w-full md:w-72 shrink-0">
235 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">
236 <img
237 src={song.cover || "/favicon.png"}
238 alt={song.title}
239 className="absolute inset-0 w-full h-full object-cover"
240 onError={(e) => {
241 (e.target as HTMLImageElement).src = "/favicon.png";
242 }}
243 />
244 </div>
245 </div>
246
247 {/* Song info */}
248 <div className="flex flex-col justify-end gap-3 min-w-0">
249 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">
250 {song.genre} • Song
251 </span>
252 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">
253 {song.title}
254 </h1>
255
256 {/* Main artist */}
257 <p className="text-xl text-gray-300 font-semibold">
258 {song.releasedBy}
259 </p>
260
261 {/* Album */}
262 {song.album && (
263 <p className="text-sm text-gray-500">
264 Album: <span className="text-gray-300">{song.album}</span>
265 </p>
266 )}
267
268 {/* Rating summary */}
269 {avgRating && (
270 <div className="flex items-center gap-2 mt-1">
271 {renderStars(Math.round(Number(avgRating)))}
272 <span className="text-sm text-gray-400">
273 {avgRating} · {song.reviews.length}{" "}
274 {song.reviews.length === 1 ? "review" : "reviews"}
275 </span>
276 </div>
277 )}
278
279 {/* Action buttons */}
280 <div className="flex items-center gap-3 mt-4">
[9c1dcc7]281 {/* Play button */}
282 {song.link && (
283 <button
284 onClick={() =>
285 play({
286 id: song.id,
287 title: song.title,
288 artist: song.releasedBy,
289 cover: song.cover,
290 embedUrl: toEmbedUrl(song.link!),
291 })
292 }
293 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${
294 currentSong?.id === song.id
295 ? "bg-white text-[#1db954]"
296 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105"
297 }`}
298 >
299 {currentSong?.id === song.id ? (
300 <>
301 <svg
302 className="w-5 h-5"
303 fill="currentColor"
304 viewBox="0 0 24 24"
305 >
306 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
307 </svg>
308 Now Playing
309 </>
310 ) : (
311 <>
312 <svg
313 className="w-5 h-5"
314 fill="currentColor"
315 viewBox="0 0 24 24"
316 >
317 <path d="M8 5v14l11-7z" />
318 </svg>
319 Play Song
320 </>
321 )}
322 </button>
323 )}
324
[8a47b30]325 {user && (
[821dc0e]326 <>
327 <button
328 onClick={toggleLike}
329 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${
330 song.isLikedByCurrentUser
331 ? "bg-[#1db954] text-black"
332 : "bg-white/10 text-white hover:bg-white/20"
333 }`}
334 >
335 {song.isLikedByCurrentUser ? (
336 <svg
337 className="w-5 h-5"
338 fill="currentColor"
339 viewBox="0 0 24 24"
340 >
341 <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" />
342 </svg>
343 ) : (
344 <svg
345 className="w-5 h-5"
346 fill="none"
347 stroke="currentColor"
348 viewBox="0 0 24 24"
349 >
350 <path
351 strokeLinecap="round"
352 strokeLinejoin="round"
353 strokeWidth={2}
354 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"
355 />
356 </svg>
357 )}
358 {song.isLikedByCurrentUser ? "Liked" : "Like"}
359 </button>
360
361 {/* Add to Playlist button */}
362 <div className="relative" ref={playlistDropdownRef}>
363 <button
364 onClick={() =>
365 setShowPlaylistDropdown(!showPlaylistDropdown)
366 }
367 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"
[8a47b30]368 >
[821dc0e]369 <svg
370 className="w-5 h-5"
371 fill="none"
372 stroke="currentColor"
373 viewBox="0 0 24 24"
374 >
375 <path
376 strokeLinecap="round"
377 strokeLinejoin="round"
378 strokeWidth={2}
379 d="M12 4v16m8-8H4"
380 />
381 </svg>
382 Add to Playlist
383 </button>
384
385 {/* Playlist dropdown */}
386 {showPlaylistDropdown && (
387 <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">
388 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10">
389 Add to playlist
390 </div>
391 <button
392 onClick={() => handleAddToPlaylist("Hello")}
393 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors"
394 >
395 Hello
396 </button>
397 <button
398 onClick={() => handleAddToPlaylist("Dimi")}
399 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors"
400 >
401 Dimi
402 </button>
403 <button
404 onClick={() => handleAddToPlaylist("lmao")}
405 className="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-white/10 transition-colors"
406 >
407 Kako hello world ama ti si mojot world
408 </button>
409 <button
410 onClick={handleCreateNewPlaylist}
411 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"
412 >
413 <svg
414 className="w-4 h-4"
415 fill="none"
416 stroke="currentColor"
417 viewBox="0 0 24 24"
418 >
419 <path
420 strokeLinecap="round"
421 strokeLinejoin="round"
422 strokeWidth={2}
423 d="M12 4v16m8-8H4"
424 />
425 </svg>
426 Create new playlist
427 </button>
428 </div>
429 )}
430 </div>
431 </>
[8a47b30]432 )}
433 </div>
434 </div>
435 </div>
436
437 {/* Credits / Contributions */}
438 {song.contributions.length > 0 && (
439 <section className="mb-10">
440 <h2 className="text-2xl font-bold mb-4">Credits</h2>
441 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">
442 {/* Main artist first */}
443 {song.contributions
444 .filter((c) => c.artistName === song.releasedBy)
445 .map((c, i) => (
446 <div
447 key={`main-${i}`}
448 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
449 >
450 <div className="flex items-center gap-3">
451 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">
452 {c.artistName.charAt(0).toUpperCase()}
453 </div>
454 <div>
455 <p className="text-white font-semibold text-lg">
456 {c.artistName}
457 </p>
458 </div>
459 </div>
460 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">
461 {formatRole(c.role)}
462 </span>
463 </div>
464 ))}
465
466 {/* Other contributors */}
467 {otherContributors.map((c, i) => (
468 <div
469 key={`contrib-${i}`}
470 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
471 >
472 <div className="flex items-center gap-3">
473 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">
474 {c.artistName.charAt(0).toUpperCase()}
475 </div>
476 <p className="text-gray-300 font-medium">{c.artistName}</p>
477 </div>
478 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">
479 {formatRole(c.role)}
480 </span>
481 </div>
482 ))}
483 </div>
484 </section>
485 )}
486
487 {/* Reviews */}
488 <section>
489 <div className="flex items-center justify-between mb-4">
490 <h2 className="text-2xl font-bold">
491 Reviews
492 {song.reviews.length > 0 && (
493 <span className="text-base font-normal text-gray-500 ml-2">
494 ({song.reviews.length})
495 </span>
496 )}
497 </h2>
498 <button
[7621d7b]499 onClick={() => setShowReviewModal(true)}
[8a47b30]500 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"
501 >
502 <svg
503 className="w-4 h-4"
504 fill="none"
505 stroke="currentColor"
506 viewBox="0 0 24 24"
507 >
508 <path
509 strokeLinecap="round"
510 strokeLinejoin="round"
511 strokeWidth={2}
512 d="M12 4v16m8-8H4"
513 />
514 </svg>
515 Add Review
516 </button>
517 </div>
518
519 {song.reviews.length === 0 ? (
520 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">
521 <p className="text-gray-500 text-lg">No reviews yet.</p>
522 <p className="text-gray-600 text-sm mt-1">
523 Be the first to share your thoughts!
524 </p>
525 </div>
526 ) : (
527 <div className="space-y-4">
528 {song.reviews.map((review) => (
529 <div
530 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}
531 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"
532 >
533 <div className="flex items-start justify-between mb-2">
534 <div className="flex items-center gap-3">
535 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">
536 <span className="text-blue-400 font-semibold text-sm">
537 {review.author.charAt(0).toUpperCase()}
538 </span>
539 </div>
540 <div>
541 <p className="text-white font-medium">
542 {review.author}
543 </p>
544 {renderStars(review.grade)}
545 </div>
546 </div>
[7621d7b]547 {user?.username === review.authorUsername && (
548 <button
549 onClick={deleteReview}
550 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10"
551 title="Delete review"
552 >
553 <svg
554 className="w-5 h-5"
555 fill="none"
556 stroke="currentColor"
557 viewBox="0 0 24 24"
558 >
559 <path
560 strokeLinecap="round"
561 strokeLinejoin="round"
562 strokeWidth={2}
563 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"
564 />
565 </svg>
566 </button>
567 )}
[8a47b30]568 </div>
569 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">
570 {review.comment}
571 </p>
572 </div>
573 ))}
574 </div>
575 )}
576 </section>
577 </div>
[7621d7b]578
579 {/* Review Modal */}
580 {showReviewModal && (
581 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
582 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl">
583 <div className="flex items-center justify-between mb-6">
584 <h3 className="text-2xl font-bold">Add Review</h3>
585 <button
586 onClick={() => {
587 setShowReviewModal(false);
588 setReviewRating(0);
589 setReviewComment("");
590 setHoverRating(0);
591 }}
592 className="text-gray-400 hover:text-white transition-colors"
593 >
594 <svg
595 className="w-6 h-6"
596 fill="none"
597 stroke="currentColor"
598 viewBox="0 0 24 24"
599 >
600 <path
601 strokeLinecap="round"
602 strokeLinejoin="round"
603 strokeWidth={2}
604 d="M6 18L18 6M6 6l12 12"
605 />
606 </svg>
607 </button>
608 </div>
609
610 {/* Star Rating */}
611 <div className="mb-6">
612 <label className="block text-sm font-medium mb-3">
613 Rating <span className="text-red-400">*</span>
614 </label>
615 <div className="flex gap-2">
616 {[1, 2, 3, 4, 5].map((star) => (
617 <button
618 key={star}
619 onClick={() => setReviewRating(star)}
620 onMouseEnter={() => setHoverRating(star)}
621 onMouseLeave={() => setHoverRating(0)}
622 className="transition-transform hover:scale-110"
623 >
624 <svg
625 className={`w-10 h-10 ${
626 star <= (hoverRating || reviewRating)
627 ? "text-yellow-400"
628 : "text-gray-600"
629 }`}
630 fill="currentColor"
631 viewBox="0 0 20 20"
632 >
633 <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" />
634 </svg>
635 </button>
636 ))}
637 </div>
638 </div>
639
640 {/* Comment */}
641 <div className="mb-6">
642 <label className="block text-sm font-medium mb-2">
643 Comment <span className="text-gray-500">(optional)</span>
644 </label>
645 <textarea
646 value={reviewComment}
647 onChange={(e) => setReviewComment(e.target.value)}
648 placeholder="Share your thoughts about this song..."
649 rows={4}
650 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"
651 />
652 </div>
653
654 {/* Actions */}
655 <div className="flex gap-3 justify-end">
656 <button
657 onClick={() => {
658 setShowReviewModal(false);
659 setReviewRating(0);
660 setReviewComment("");
661 setHoverRating(0);
662 }}
663 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors"
664 >
665 Cancel
666 </button>
667 <button
668 onClick={handleSubmitReview}
669 disabled={reviewRating === 0}
670 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"
671 >
672 Submit
673 </button>
674 </div>
675 </div>
676 </div>
677 )}
[8a47b30]678 </div>
679 );
680};
681
682export default SongDetail;
Note: See TracBrowser for help on using the repository browser.