source: frontend/src/pages/SongDetail.tsx@ 8a47b30

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

add endpoint for fetching aggregated song details and song detail view

  • Property mode set to 100644
File size: 11.8 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 type { SongDetail as SongDetailType } from "../utils/types";
6
7const ROLE_LABELS: Record<string, string> = {
8 MAIN_VOCAL: "Main Vocal",
9 FEATURED: "Featured",
10 PRODUCER: "Producer",
11 SONGWRITER: "Songwriter",
12 COMPOSER: "Composer",
13 MIXER: "Mixer",
14 ENGINEER: "Engineer",
15};
16
17const formatRole = (role: string): string => {
18 return ROLE_LABELS[role] || role.replace(/_/g, " ");
19};
20
21// convert a regular youtube URL to an embeddable URL
22const toEmbedUrl = (url: string): string => {
23 try {
24 const parsed = new URL(url);
25 // youtube.com/watch?v=ID
26 if (
27 (parsed.hostname === "www.youtube.com" ||
28 parsed.hostname === "youtube.com") &&
29 parsed.searchParams.has("v")
30 ) {
31 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`;
32 }
33 // youtu.be/ID
34 if (parsed.hostname === "youtu.be") {
35 return `https://www.youtube.com/embed${parsed.pathname}`;
36 }
37 // already an embed URL or other provider – return as-is
38 return url;
39 } catch {
40 return url;
41 }
42};
43
44const renderStars = (grade: number) => {
45 return (
46 <div className="flex gap-0.5">
47 {[1, 2, 3, 4, 5].map((star) => (
48 <svg
49 key={star}
50 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`}
51 fill="currentColor"
52 viewBox="0 0 20 20"
53 >
54 <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" />
55 </svg>
56 ))}
57 </div>
58 );
59};
60
61const SongDetail = () => {
62 const { id } = useParams<{ id: string }>();
63 const { user } = useAuth();
64 const [song, setSong] = useState<SongDetailType | null>(null);
65 const [loading, setLoading] = useState(true);
66 const [error, setError] = useState<string | null>(null);
67
68 useEffect(() => {
69 const fetchSong = async () => {
70 try {
71 setLoading(true);
72 const response = await axiosInstance.get(`/songs/${id}/details`);
73 setSong(response.data);
74 } catch (err) {
75 console.error("Error fetching song details:", err);
76 setError("Failed to load song details.");
77 } finally {
78 setLoading(false);
79 }
80 };
81 if (id) fetchSong();
82 }, [id]);
83
84 const toggleLike = async () => {
85 if (!song) return;
86 try {
87 await axiosInstance.post(`/musical-entity/${song.id}/like`);
88 setSong((prev) =>
89 prev
90 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }
91 : prev,
92 );
93 } catch (err) {
94 console.error("Error toggling like:", err);
95 }
96 };
97
98 if (loading) {
99 return (
100 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
101 <div className="flex flex-col items-center gap-4">
102 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />
103 <p className="text-gray-400 text-lg">Loading song…</p>
104 </div>
105 </div>
106 );
107 }
108
109 if (error || !song) {
110 return (
111 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
112 <div className="text-center">
113 <p className="text-red-400 text-xl mb-4">
114 {error ?? "Song not found"}
115 </p>
116 <Link to="/" className="text-[#1db954] hover:underline text-sm">
117 ← Back to Home
118 </Link>
119 </div>
120 </div>
121 );
122 }
123
124 const otherContributors = song.contributions.filter(
125 (c) => c.artistName !== song.releasedBy,
126 );
127
128 const avgRating =
129 song.reviews.length > 0
130 ? (
131 song.reviews.reduce((sum, r) => sum + r.grade, 0) /
132 song.reviews.length
133 ).toFixed(1)
134 : null;
135
136 return (
137 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
138 <div className="max-w-5xl mx-auto px-6 py-10">
139 {/* Back link */}
140 <Link
141 to="/"
142 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
143 >
144 <svg
145 className="w-4 h-4"
146 fill="none"
147 stroke="currentColor"
148 viewBox="0 0 24 24"
149 >
150 <path
151 strokeLinecap="round"
152 strokeLinejoin="round"
153 strokeWidth={2}
154 d="M15 19l-7-7 7-7"
155 />
156 </svg>
157 Back to Home
158 </Link>
159
160 {/* Hero section */}
161 <div className="flex flex-col md:flex-row gap-8 mb-10">
162 {/* Cover art */}
163 <div className="w-full md:w-72 shrink-0">
164 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">
165 <img
166 src={song.cover || "/favicon.png"}
167 alt={song.title}
168 className="absolute inset-0 w-full h-full object-cover"
169 onError={(e) => {
170 (e.target as HTMLImageElement).src = "/favicon.png";
171 }}
172 />
173 </div>
174 </div>
175
176 {/* Song info */}
177 <div className="flex flex-col justify-end gap-3 min-w-0">
178 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">
179 {song.genre} • Song
180 </span>
181 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">
182 {song.title}
183 </h1>
184
185 {/* Main artist */}
186 <p className="text-xl text-gray-300 font-semibold">
187 {song.releasedBy}
188 </p>
189
190 {/* Album */}
191 {song.album && (
192 <p className="text-sm text-gray-500">
193 Album: <span className="text-gray-300">{song.album}</span>
194 </p>
195 )}
196
197 {/* Rating summary */}
198 {avgRating && (
199 <div className="flex items-center gap-2 mt-1">
200 {renderStars(Math.round(Number(avgRating)))}
201 <span className="text-sm text-gray-400">
202 {avgRating} · {song.reviews.length}{" "}
203 {song.reviews.length === 1 ? "review" : "reviews"}
204 </span>
205 </div>
206 )}
207
208 {/* Action buttons */}
209 {/* todo: add add to playlist button */}
210 <div className="flex items-center gap-3 mt-4">
211 {user && (
212 <button
213 onClick={toggleLike}
214 className={`flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-semibold transition-colors cursor-pointer ${
215 song.isLikedByCurrentUser
216 ? "bg-[#1db954] text-black"
217 : "bg-white/10 text-white hover:bg-white/20"
218 }`}
219 >
220 {song.isLikedByCurrentUser ? (
221 <svg
222 className="w-5 h-5"
223 fill="currentColor"
224 viewBox="0 0 24 24"
225 >
226 <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" />
227 </svg>
228 ) : (
229 <svg
230 className="w-5 h-5"
231 fill="none"
232 stroke="currentColor"
233 viewBox="0 0 24 24"
234 >
235 <path
236 strokeLinecap="round"
237 strokeLinejoin="round"
238 strokeWidth={2}
239 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"
240 />
241 </svg>
242 )}
243 {song.isLikedByCurrentUser ? "Liked" : "Like"}
244 </button>
245 )}
246 </div>
247 </div>
248 </div>
249
250 {/* YouTube embed */}
251 {song.link && (
252 <section className="mb-10">
253 <div className="rounded-xl overflow-hidden shadow-lg aspect-video bg-black">
254 <iframe
255 src={toEmbedUrl(song.link)}
256 title={song.title}
257 className="w-full h-full"
258 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
259 allowFullScreen
260 />
261 </div>
262 </section>
263 )}
264
265 {/* Credits / Contributions */}
266 {song.contributions.length > 0 && (
267 <section className="mb-10">
268 <h2 className="text-2xl font-bold mb-4">Credits</h2>
269 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">
270 {/* Main artist first */}
271 {song.contributions
272 .filter((c) => c.artistName === song.releasedBy)
273 .map((c, i) => (
274 <div
275 key={`main-${i}`}
276 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
277 >
278 <div className="flex items-center gap-3">
279 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">
280 {c.artistName.charAt(0).toUpperCase()}
281 </div>
282 <div>
283 <p className="text-white font-semibold text-lg">
284 {c.artistName}
285 </p>
286 </div>
287 </div>
288 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">
289 {formatRole(c.role)}
290 </span>
291 </div>
292 ))}
293
294 {/* Other contributors */}
295 {otherContributors.map((c, i) => (
296 <div
297 key={`contrib-${i}`}
298 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
299 >
300 <div className="flex items-center gap-3">
301 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">
302 {c.artistName.charAt(0).toUpperCase()}
303 </div>
304 <p className="text-gray-300 font-medium">{c.artistName}</p>
305 </div>
306 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">
307 {formatRole(c.role)}
308 </span>
309 </div>
310 ))}
311 </div>
312 </section>
313 )}
314
315 {/* Reviews */}
316 <section>
317 <div className="flex items-center justify-between mb-4">
318 <h2 className="text-2xl font-bold">
319 Reviews
320 {song.reviews.length > 0 && (
321 <span className="text-base font-normal text-gray-500 ml-2">
322 ({song.reviews.length})
323 </span>
324 )}
325 </h2>
326 <button
327 onClick={() => {
328 /* TODO: add review modal */
329 }}
330 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"
331 >
332 <svg
333 className="w-4 h-4"
334 fill="none"
335 stroke="currentColor"
336 viewBox="0 0 24 24"
337 >
338 <path
339 strokeLinecap="round"
340 strokeLinejoin="round"
341 strokeWidth={2}
342 d="M12 4v16m8-8H4"
343 />
344 </svg>
345 Add Review
346 </button>
347 </div>
348
349 {song.reviews.length === 0 ? (
350 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">
351 <p className="text-gray-500 text-lg">No reviews yet.</p>
352 <p className="text-gray-600 text-sm mt-1">
353 Be the first to share your thoughts!
354 </p>
355 </div>
356 ) : (
357 <div className="space-y-4">
358 {song.reviews.map((review) => (
359 <div
360 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}
361 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"
362 >
363 <div className="flex items-start justify-between mb-2">
364 <div className="flex items-center gap-3">
365 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">
366 <span className="text-blue-400 font-semibold text-sm">
367 {review.author.charAt(0).toUpperCase()}
368 </span>
369 </div>
370 <div>
371 <p className="text-white font-medium">
372 {review.author}
373 </p>
374 {renderStars(review.grade)}
375 </div>
376 </div>
377 <span className="text-sm text-gray-500 bg-white/5 px-2.5 py-0.5 rounded-full">
378 {review.grade}/5
379 </span>
380 </div>
381 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">
382 {review.comment}
383 </p>
384 </div>
385 ))}
386 </div>
387 )}
388 </section>
389 </div>
390 </div>
391 );
392};
393
394export default SongDetail;
Note: See TracBrowser for help on using the repository browser.