source: frontend/src/pages/SongDetail.tsx@ 7621d7b

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

implement adding a review

  • Property mode set to 100644
File size: 17.7 KB
Line 
1import { useEffect, useState } from "react";
2import { Link, useParams } from "react-router-dom";
3import axiosInstance from "../api/axiosInstance";
4import { useAuth } from "../context/authContext";
5import { usePlayer } from "../context/playerContext";
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();
65 const { play, currentSong } = usePlayer();
66 const [song, setSong] = useState<SongDetailType | null>(null);
67 const [loading, setLoading] = useState(true);
68 const [error, setError] = useState<string | null>(null);
69 const [showReviewModal, setShowReviewModal] = useState(false);
70 const [reviewRating, setReviewRating] = useState(0);
71 const [reviewComment, setReviewComment] = useState("");
72 const [hoverRating, setHoverRating] = useState(0);
73 console.log(user);
74 useEffect(() => {
75 const fetchSong = async () => {
76 try {
77 setLoading(true);
78 const response = await axiosInstance.get(`/songs/${id}/details`);
79 setSong(response.data);
80 // Don't autoplay - user must click play button
81 } catch (err) {
82 console.error("Error fetching song details:", err);
83 setError("Failed to load song details.");
84 } finally {
85 setLoading(false);
86 }
87 };
88 if (id) fetchSong();
89 }, [id]);
90
91 const handleSubmitReview = async () => {
92 if (reviewRating === 0) {
93 // todo: replace with toast
94 alert("Please select a rating");
95 return;
96 }
97 try {
98 await axiosInstance.post(`reviews/${song?.id}`, {
99 grade: reviewRating,
100 comment: reviewComment,
101 });
102 const response = await axiosInstance.get(`/songs/${id}/details`);
103 setSong(response.data);
104 } catch (err) {
105 // todo: replace with toast
106 console.error("Error submitting review:", err);
107 } finally {
108 setShowReviewModal(false);
109 setReviewRating(0);
110 setReviewComment("");
111 setHoverRating(0);
112 }
113 };
114
115 const deleteReview = async () => {
116 try {
117 await axiosInstance.delete(`reviews/${song?.id}`);
118 const response = await axiosInstance.get(`/songs/${id}/details`);
119 setSong(response.data);
120 } catch (err) {
121 console.error("Error deleting review:", err);
122 }
123 };
124
125 const toggleLike = async () => {
126 if (!song) return;
127 try {
128 await axiosInstance.post(`/musical-entity/${song.id}/like`);
129 setSong((prev) =>
130 prev
131 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }
132 : prev,
133 );
134 } catch (err) {
135 console.error("Error toggling like:", err);
136 }
137 };
138
139 if (loading) {
140 return (
141 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
142 <div className="flex flex-col items-center gap-4">
143 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />
144 <p className="text-gray-400 text-lg">Loading song…</p>
145 </div>
146 </div>
147 );
148 }
149
150 if (error || !song) {
151 return (
152 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
153 <div className="text-center">
154 <p className="text-red-400 text-xl mb-4">
155 {error ?? "Song not found"}
156 </p>
157 <Link to="/" className="text-[#1db954] hover:underline text-sm">
158 ← Back to Home
159 </Link>
160 </div>
161 </div>
162 );
163 }
164
165 const otherContributors = song.contributions.filter(
166 (c) => c.artistName !== song.releasedBy,
167 );
168
169 const avgRating =
170 song.reviews.length > 0
171 ? (
172 song.reviews.reduce((sum, r) => sum + r.grade, 0) /
173 song.reviews.length
174 ).toFixed(1)
175 : null;
176
177 return (
178 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
179 <div className="max-w-5xl mx-auto px-6 py-10">
180 {/* Back link */}
181 <Link
182 to="/"
183 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
184 >
185 <svg
186 className="w-4 h-4"
187 fill="none"
188 stroke="currentColor"
189 viewBox="0 0 24 24"
190 >
191 <path
192 strokeLinecap="round"
193 strokeLinejoin="round"
194 strokeWidth={2}
195 d="M15 19l-7-7 7-7"
196 />
197 </svg>
198 Back to Home
199 </Link>
200
201 {/* Hero section */}
202 <div className="flex flex-col md:flex-row gap-8 mb-10">
203 {/* Cover art */}
204 <div className="w-full md:w-72 shrink-0">
205 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">
206 <img
207 src={song.cover || "/favicon.png"}
208 alt={song.title}
209 className="absolute inset-0 w-full h-full object-cover"
210 onError={(e) => {
211 (e.target as HTMLImageElement).src = "/favicon.png";
212 }}
213 />
214 </div>
215 </div>
216
217 {/* Song info */}
218 <div className="flex flex-col justify-end gap-3 min-w-0">
219 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">
220 {song.genre} • Song
221 </span>
222 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">
223 {song.title}
224 </h1>
225
226 {/* Main artist */}
227 <p className="text-xl text-gray-300 font-semibold">
228 {song.releasedBy}
229 </p>
230
231 {/* Album */}
232 {song.album && (
233 <p className="text-sm text-gray-500">
234 Album: <span className="text-gray-300">{song.album}</span>
235 </p>
236 )}
237
238 {/* Rating summary */}
239 {avgRating && (
240 <div className="flex items-center gap-2 mt-1">
241 {renderStars(Math.round(Number(avgRating)))}
242 <span className="text-sm text-gray-400">
243 {avgRating} · {song.reviews.length}{" "}
244 {song.reviews.length === 1 ? "review" : "reviews"}
245 </span>
246 </div>
247 )}
248
249 {/* Action buttons */}
250 <div className="flex items-center gap-3 mt-4">
251 {/* Play button */}
252 {song.link && (
253 <button
254 onClick={() =>
255 play({
256 id: song.id,
257 title: song.title,
258 artist: song.releasedBy,
259 cover: song.cover,
260 embedUrl: toEmbedUrl(song.link!),
261 })
262 }
263 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${
264 currentSong?.id === song.id
265 ? "bg-white text-[#1db954]"
266 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105"
267 }`}
268 >
269 {currentSong?.id === song.id ? (
270 <>
271 <svg
272 className="w-5 h-5"
273 fill="currentColor"
274 viewBox="0 0 24 24"
275 >
276 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
277 </svg>
278 Now Playing
279 </>
280 ) : (
281 <>
282 <svg
283 className="w-5 h-5"
284 fill="currentColor"
285 viewBox="0 0 24 24"
286 >
287 <path d="M8 5v14l11-7z" />
288 </svg>
289 Play Song
290 </>
291 )}
292 </button>
293 )}
294
295 {user && (
296 <button
297 onClick={toggleLike}
298 className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold transition-colors cursor-pointer ${
299 song.isLikedByCurrentUser
300 ? "bg-[#1db954] text-black"
301 : "bg-white/10 text-white hover:bg-white/20"
302 }`}
303 >
304 {song.isLikedByCurrentUser ? (
305 <svg
306 className="w-5 h-5"
307 fill="currentColor"
308 viewBox="0 0 24 24"
309 >
310 <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" />
311 </svg>
312 ) : (
313 <svg
314 className="w-5 h-5"
315 fill="none"
316 stroke="currentColor"
317 viewBox="0 0 24 24"
318 >
319 <path
320 strokeLinecap="round"
321 strokeLinejoin="round"
322 strokeWidth={2}
323 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"
324 />
325 </svg>
326 )}
327 {song.isLikedByCurrentUser ? "Liked" : "Like"}
328 </button>
329 )}
330 </div>
331 </div>
332 </div>
333
334 {/* Credits / Contributions */}
335 {song.contributions.length > 0 && (
336 <section className="mb-10">
337 <h2 className="text-2xl font-bold mb-4">Credits</h2>
338 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">
339 {/* Main artist first */}
340 {song.contributions
341 .filter((c) => c.artistName === song.releasedBy)
342 .map((c, i) => (
343 <div
344 key={`main-${i}`}
345 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
346 >
347 <div className="flex items-center gap-3">
348 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">
349 {c.artistName.charAt(0).toUpperCase()}
350 </div>
351 <div>
352 <p className="text-white font-semibold text-lg">
353 {c.artistName}
354 </p>
355 </div>
356 </div>
357 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">
358 {formatRole(c.role)}
359 </span>
360 </div>
361 ))}
362
363 {/* Other contributors */}
364 {otherContributors.map((c, i) => (
365 <div
366 key={`contrib-${i}`}
367 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
368 >
369 <div className="flex items-center gap-3">
370 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">
371 {c.artistName.charAt(0).toUpperCase()}
372 </div>
373 <p className="text-gray-300 font-medium">{c.artistName}</p>
374 </div>
375 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">
376 {formatRole(c.role)}
377 </span>
378 </div>
379 ))}
380 </div>
381 </section>
382 )}
383
384 {/* Reviews */}
385 <section>
386 <div className="flex items-center justify-between mb-4">
387 <h2 className="text-2xl font-bold">
388 Reviews
389 {song.reviews.length > 0 && (
390 <span className="text-base font-normal text-gray-500 ml-2">
391 ({song.reviews.length})
392 </span>
393 )}
394 </h2>
395 <button
396 onClick={() => setShowReviewModal(true)}
397 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"
398 >
399 <svg
400 className="w-4 h-4"
401 fill="none"
402 stroke="currentColor"
403 viewBox="0 0 24 24"
404 >
405 <path
406 strokeLinecap="round"
407 strokeLinejoin="round"
408 strokeWidth={2}
409 d="M12 4v16m8-8H4"
410 />
411 </svg>
412 Add Review
413 </button>
414 </div>
415
416 {song.reviews.length === 0 ? (
417 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">
418 <p className="text-gray-500 text-lg">No reviews yet.</p>
419 <p className="text-gray-600 text-sm mt-1">
420 Be the first to share your thoughts!
421 </p>
422 </div>
423 ) : (
424 <div className="space-y-4">
425 {song.reviews.map((review) => (
426 <div
427 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}
428 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"
429 >
430 <div className="flex items-start justify-between mb-2">
431 <div className="flex items-center gap-3">
432 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">
433 <span className="text-blue-400 font-semibold text-sm">
434 {review.author.charAt(0).toUpperCase()}
435 </span>
436 </div>
437 <div>
438 <p className="text-white font-medium">
439 {review.author}
440 </p>
441 {renderStars(review.grade)}
442 </div>
443 </div>
444 {user?.username === review.authorUsername && (
445 <button
446 onClick={deleteReview}
447 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10"
448 title="Delete review"
449 >
450 <svg
451 className="w-5 h-5"
452 fill="none"
453 stroke="currentColor"
454 viewBox="0 0 24 24"
455 >
456 <path
457 strokeLinecap="round"
458 strokeLinejoin="round"
459 strokeWidth={2}
460 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"
461 />
462 </svg>
463 </button>
464 )}
465 </div>
466 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">
467 {review.comment}
468 </p>
469 </div>
470 ))}
471 </div>
472 )}
473 </section>
474 </div>
475
476 {/* Review Modal */}
477 {showReviewModal && (
478 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
479 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl">
480 <div className="flex items-center justify-between mb-6">
481 <h3 className="text-2xl font-bold">Add Review</h3>
482 <button
483 onClick={() => {
484 setShowReviewModal(false);
485 setReviewRating(0);
486 setReviewComment("");
487 setHoverRating(0);
488 }}
489 className="text-gray-400 hover:text-white transition-colors"
490 >
491 <svg
492 className="w-6 h-6"
493 fill="none"
494 stroke="currentColor"
495 viewBox="0 0 24 24"
496 >
497 <path
498 strokeLinecap="round"
499 strokeLinejoin="round"
500 strokeWidth={2}
501 d="M6 18L18 6M6 6l12 12"
502 />
503 </svg>
504 </button>
505 </div>
506
507 {/* Star Rating */}
508 <div className="mb-6">
509 <label className="block text-sm font-medium mb-3">
510 Rating <span className="text-red-400">*</span>
511 </label>
512 <div className="flex gap-2">
513 {[1, 2, 3, 4, 5].map((star) => (
514 <button
515 key={star}
516 onClick={() => setReviewRating(star)}
517 onMouseEnter={() => setHoverRating(star)}
518 onMouseLeave={() => setHoverRating(0)}
519 className="transition-transform hover:scale-110"
520 >
521 <svg
522 className={`w-10 h-10 ${
523 star <= (hoverRating || reviewRating)
524 ? "text-yellow-400"
525 : "text-gray-600"
526 }`}
527 fill="currentColor"
528 viewBox="0 0 20 20"
529 >
530 <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" />
531 </svg>
532 </button>
533 ))}
534 </div>
535 </div>
536
537 {/* Comment */}
538 <div className="mb-6">
539 <label className="block text-sm font-medium mb-2">
540 Comment <span className="text-gray-500">(optional)</span>
541 </label>
542 <textarea
543 value={reviewComment}
544 onChange={(e) => setReviewComment(e.target.value)}
545 placeholder="Share your thoughts about this song..."
546 rows={4}
547 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"
548 />
549 </div>
550
551 {/* Actions */}
552 <div className="flex gap-3 justify-end">
553 <button
554 onClick={() => {
555 setShowReviewModal(false);
556 setReviewRating(0);
557 setReviewComment("");
558 setHoverRating(0);
559 }}
560 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors"
561 >
562 Cancel
563 </button>
564 <button
565 onClick={handleSubmitReview}
566 disabled={reviewRating === 0}
567 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"
568 >
569 Submit
570 </button>
571 </div>
572 </div>
573 </div>
574 )}
575 </div>
576 );
577};
578
579export default SongDetail;
Note: See TracBrowser for help on using the repository browser.