source: frontend/src/pages/SongDetail.tsx@ 8675f75

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

add links to song api responses

  • Property mode set to 100644
File size: 14.0 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
70 useEffect(() => {
71 const fetchSong = async () => {
72 try {
73 setLoading(true);
74 const response = await axiosInstance.get(`/songs/${id}/details`);
75 setSong(response.data);
76 // Don't autoplay - user must click play button
77 } catch (err) {
78 console.error("Error fetching song details:", err);
79 setError("Failed to load song details.");
80 } finally {
81 setLoading(false);
82 }
83 };
84 if (id) fetchSong();
85 }, [id]);
86
87 const toggleLike = async () => {
88 if (!song) return;
89 try {
90 await axiosInstance.post(`/musical-entity/${song.id}/like`);
91 setSong((prev) =>
92 prev
93 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }
94 : prev,
95 );
96 } catch (err) {
97 console.error("Error toggling like:", err);
98 }
99 };
100
101 if (loading) {
102 return (
103 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
104 <div className="flex flex-col items-center gap-4">
105 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />
106 <p className="text-gray-400 text-lg">Loading song…</p>
107 </div>
108 </div>
109 );
110 }
111
112 if (error || !song) {
113 return (
114 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
115 <div className="text-center">
116 <p className="text-red-400 text-xl mb-4">
117 {error ?? "Song not found"}
118 </p>
119 <Link to="/" className="text-[#1db954] hover:underline text-sm">
120 ← Back to Home
121 </Link>
122 </div>
123 </div>
124 );
125 }
126
127 const otherContributors = song.contributions.filter(
128 (c) => c.artistName !== song.releasedBy,
129 );
130
131 const avgRating =
132 song.reviews.length > 0
133 ? (
134 song.reviews.reduce((sum, r) => sum + r.grade, 0) /
135 song.reviews.length
136 ).toFixed(1)
137 : null;
138
139 return (
140 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
141 <div className="max-w-5xl mx-auto px-6 py-10">
142 {/* Back link */}
143 <Link
144 to="/"
145 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
146 >
147 <svg
148 className="w-4 h-4"
149 fill="none"
150 stroke="currentColor"
151 viewBox="0 0 24 24"
152 >
153 <path
154 strokeLinecap="round"
155 strokeLinejoin="round"
156 strokeWidth={2}
157 d="M15 19l-7-7 7-7"
158 />
159 </svg>
160 Back to Home
161 </Link>
162
163 {/* Hero section */}
164 <div className="flex flex-col md:flex-row gap-8 mb-10">
165 {/* Cover art */}
166 <div className="w-full md:w-72 shrink-0">
167 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">
168 <img
169 src={song.cover || "/favicon.png"}
170 alt={song.title}
171 className="absolute inset-0 w-full h-full object-cover"
172 onError={(e) => {
173 (e.target as HTMLImageElement).src = "/favicon.png";
174 }}
175 />
176 </div>
177 </div>
178
179 {/* Song info */}
180 <div className="flex flex-col justify-end gap-3 min-w-0">
181 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">
182 {song.genre} • Song
183 </span>
184 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">
185 {song.title}
186 </h1>
187
188 {/* Main artist */}
189 <p className="text-xl text-gray-300 font-semibold">
190 {song.releasedBy}
191 </p>
192
193 {/* Album */}
194 {song.album && (
195 <p className="text-sm text-gray-500">
196 Album: <span className="text-gray-300">{song.album}</span>
197 </p>
198 )}
199
200 {/* Rating summary */}
201 {avgRating && (
202 <div className="flex items-center gap-2 mt-1">
203 {renderStars(Math.round(Number(avgRating)))}
204 <span className="text-sm text-gray-400">
205 {avgRating} · {song.reviews.length}{" "}
206 {song.reviews.length === 1 ? "review" : "reviews"}
207 </span>
208 </div>
209 )}
210
211 {/* Action buttons */}
212 <div className="flex items-center gap-3 mt-4">
213 {/* Play button */}
214 {song.link && (
215 <button
216 onClick={() =>
217 play({
218 id: song.id,
219 title: song.title,
220 artist: song.releasedBy,
221 cover: song.cover,
222 embedUrl: toEmbedUrl(song.link!),
223 })
224 }
225 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${
226 currentSong?.id === song.id
227 ? "bg-white text-[#1db954]"
228 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105"
229 }`}
230 >
231 {currentSong?.id === song.id ? (
232 <>
233 <svg
234 className="w-5 h-5"
235 fill="currentColor"
236 viewBox="0 0 24 24"
237 >
238 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
239 </svg>
240 Now Playing
241 </>
242 ) : (
243 <>
244 <svg
245 className="w-5 h-5"
246 fill="currentColor"
247 viewBox="0 0 24 24"
248 >
249 <path d="M8 5v14l11-7z" />
250 </svg>
251 Play Song
252 </>
253 )}
254 </button>
255 )}
256
257 {user && (
258 <button
259 onClick={toggleLike}
260 className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold transition-colors cursor-pointer ${
261 song.isLikedByCurrentUser
262 ? "bg-[#1db954] text-black"
263 : "bg-white/10 text-white hover:bg-white/20"
264 }`}
265 >
266 {song.isLikedByCurrentUser ? (
267 <svg
268 className="w-5 h-5"
269 fill="currentColor"
270 viewBox="0 0 24 24"
271 >
272 <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" />
273 </svg>
274 ) : (
275 <svg
276 className="w-5 h-5"
277 fill="none"
278 stroke="currentColor"
279 viewBox="0 0 24 24"
280 >
281 <path
282 strokeLinecap="round"
283 strokeLinejoin="round"
284 strokeWidth={2}
285 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"
286 />
287 </svg>
288 )}
289 {song.isLikedByCurrentUser ? "Liked" : "Like"}
290 </button>
291 )}
292 </div>
293 </div>
294 </div>
295
296 {/* Play button — plays in persistent mini-player */}
297 {song.link && (
298 <section className="mb-10">
299 {currentSong?.id === song.id ? (
300 <div className="bg-[#1a1a2e]/60 rounded-xl p-4 flex items-center gap-3">
301 <div className="w-10 h-10 rounded-full bg-[#1db954] flex items-center justify-center text-black animate-pulse">
302 <svg
303 className="w-5 h-5"
304 fill="currentColor"
305 viewBox="0 0 24 24"
306 >
307 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
308 </svg>
309 </div>
310 <p className="text-[#1db954] font-medium">
311 Now playing in mini-player below
312 </p>
313 </div>
314 ) : (
315 <button
316 onClick={() =>
317 play({
318 id: song.id,
319 title: song.title,
320 artist: song.releasedBy,
321 cover: song.cover,
322 embedUrl: toEmbedUrl(song.link!),
323 })
324 }
325 className="w-full bg-[#1a1a2e]/60 hover:bg-[#1a1a2e]/80 rounded-xl p-4 flex items-center gap-3 transition-colors cursor-pointer group"
326 >
327 <div className="w-10 h-10 rounded-full bg-[#1db954] flex items-center justify-center text-black group-hover:scale-105 transition-transform">
328 <svg
329 className="w-5 h-5"
330 fill="currentColor"
331 viewBox="0 0 24 24"
332 >
333 <path d="M8 5v14l11-7z" />
334 </svg>
335 </div>
336 <p className="text-white font-medium">Play this song</p>
337 </button>
338 )}
339 </section>
340 )}
341
342 {/* Credits / Contributions */}
343 {song.contributions.length > 0 && (
344 <section className="mb-10">
345 <h2 className="text-2xl font-bold mb-4">Credits</h2>
346 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">
347 {/* Main artist first */}
348 {song.contributions
349 .filter((c) => c.artistName === song.releasedBy)
350 .map((c, i) => (
351 <div
352 key={`main-${i}`}
353 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
354 >
355 <div className="flex items-center gap-3">
356 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">
357 {c.artistName.charAt(0).toUpperCase()}
358 </div>
359 <div>
360 <p className="text-white font-semibold text-lg">
361 {c.artistName}
362 </p>
363 </div>
364 </div>
365 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">
366 {formatRole(c.role)}
367 </span>
368 </div>
369 ))}
370
371 {/* Other contributors */}
372 {otherContributors.map((c, i) => (
373 <div
374 key={`contrib-${i}`}
375 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
376 >
377 <div className="flex items-center gap-3">
378 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">
379 {c.artistName.charAt(0).toUpperCase()}
380 </div>
381 <p className="text-gray-300 font-medium">{c.artistName}</p>
382 </div>
383 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">
384 {formatRole(c.role)}
385 </span>
386 </div>
387 ))}
388 </div>
389 </section>
390 )}
391
392 {/* Reviews */}
393 <section>
394 <div className="flex items-center justify-between mb-4">
395 <h2 className="text-2xl font-bold">
396 Reviews
397 {song.reviews.length > 0 && (
398 <span className="text-base font-normal text-gray-500 ml-2">
399 ({song.reviews.length})
400 </span>
401 )}
402 </h2>
403 <button
404 onClick={() => {
405 /* TODO: add review modal */
406 }}
407 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"
408 >
409 <svg
410 className="w-4 h-4"
411 fill="none"
412 stroke="currentColor"
413 viewBox="0 0 24 24"
414 >
415 <path
416 strokeLinecap="round"
417 strokeLinejoin="round"
418 strokeWidth={2}
419 d="M12 4v16m8-8H4"
420 />
421 </svg>
422 Add Review
423 </button>
424 </div>
425
426 {song.reviews.length === 0 ? (
427 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">
428 <p className="text-gray-500 text-lg">No reviews yet.</p>
429 <p className="text-gray-600 text-sm mt-1">
430 Be the first to share your thoughts!
431 </p>
432 </div>
433 ) : (
434 <div className="space-y-4">
435 {song.reviews.map((review) => (
436 <div
437 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}
438 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"
439 >
440 <div className="flex items-start justify-between mb-2">
441 <div className="flex items-center gap-3">
442 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">
443 <span className="text-blue-400 font-semibold text-sm">
444 {review.author.charAt(0).toUpperCase()}
445 </span>
446 </div>
447 <div>
448 <p className="text-white font-medium">
449 {review.author}
450 </p>
451 {renderStars(review.grade)}
452 </div>
453 </div>
454 </div>
455 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">
456 {review.comment}
457 </p>
458 </div>
459 ))}
460 </div>
461 )}
462 </section>
463 </div>
464 </div>
465 );
466};
467
468export default SongDetail;
Note: See TracBrowser for help on using the repository browser.