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

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

clickable items now take the user to the respective pages; added artist username and album id to dto-s

  • Property mode set to 100644
File size: 18.7 KB
RevLine 
[b071fb4]1import { useEffect, useRef, useState } from "react";
[9c1dcc7]2import { useNavigate } from "react-router-dom";
[694fc25]3import axiosInstance from "../api/axiosInstance";
[cb4a1da]4import AlbumResult from "../components/search/AlbumResult";
5import SongResult from "../components/search/SongResult";
6import UserResult from "../components/search/UserResult";
[9c1dcc7]7import { usePlayer } from "../context/playerContext";
[cb4a1da]8import type {
9 Album,
10 BaseNonAdminUser,
11 SearchCategory,
12 Song,
13} from "../utils/types";
14
[9c1dcc7]15// Convert a regular YouTube URL to an embeddable URL
16const toEmbedUrl = (url: string): string => {
17 try {
18 const parsed = new URL(url);
19 if (
20 (parsed.hostname === "www.youtube.com" ||
21 parsed.hostname === "youtube.com") &&
22 parsed.searchParams.has("v")
23 ) {
24 return `https://www.youtube.com/embed/${parsed.searchParams.get("v")}`;
25 }
26 if (parsed.hostname === "youtu.be") {
27 return `https://www.youtube.com/embed${parsed.pathname}`;
28 }
29 return url;
30 } catch {
31 return url;
32 }
33};
34
[cb4a1da]35const CATEGORIES: { value: SearchCategory; label: string }[] = [
36 { value: "songs", label: "Songs" },
37 { value: "albums", label: "Albums" },
38 { value: "artists", label: "Artists" },
39 { value: "users", label: "Users" },
40];
[694fc25]41
[0c86e77]42const LandingPage = () => {
[9c1dcc7]43 const navigate = useNavigate();
44 const { play, currentSong } = usePlayer();
[694fc25]45 const [songs, setSongs] = useState<Song[]>([]);
46 const [loading, setLoading] = useState<boolean>(true);
[cb4a1da]47
48 // search state
49 const [searchInput, setSearchInput] = useState("");
50 const [activeQuery, setActiveQuery] = useState("");
51 const [searchCategory, setSearchCategory] = useState<SearchCategory>("songs");
52 const [searchResults, setSearchResults] = useState<unknown[]>([]);
53 const [searchLoading, setSearchLoading] = useState(false);
54 const [hasSearched, setHasSearched] = useState(false);
[694fc25]55
[b071fb4]56 // playlist dropdown state
57 const [openPlaylistDropdown, setOpenPlaylistDropdown] = useState<
58 number | null
59 >(null);
60 const playlistDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null }>(
61 {},
62 );
63
[694fc25]64 useEffect(() => {
65 const fetchSongs = async () => {
66 try {
[cb4a1da]67 const response = await axiosInstance.get("/songs/top");
68 setSongs(response.data);
[694fc25]69 } catch (error) {
70 console.error("Error fetching songs:", error);
71 } finally {
72 setLoading(false);
73 }
74 };
75 fetchSongs();
76 }, []);
77
[b071fb4]78 // Handle click outside for playlist dropdown
79 useEffect(() => {
80 const handleClickOutside = (event: MouseEvent) => {
81 if (openPlaylistDropdown !== null) {
82 const ref = playlistDropdownRefs.current[openPlaylistDropdown];
83 if (ref && !ref.contains(event.target as Node)) {
84 setOpenPlaylistDropdown(null);
85 }
86 }
87 };
88
89 document.addEventListener("mousedown", handleClickOutside);
90 return () => {
91 document.removeEventListener("mousedown", handleClickOutside);
92 };
93 }, [openPlaylistDropdown]);
94
95 const toggleLike = async (songId: number) => {
96 try {
97 await axiosInstance.post(`/musical-entity/${songId}/like`);
98 setSongs((prevSongs) =>
99 prevSongs.map((song) =>
100 song.id === songId
101 ? { ...song, isLikedByCurrentUser: !song.isLikedByCurrentUser }
102 : song,
103 ),
104 );
105 } catch (error) {
106 console.error("Error toggling like:", error);
107 }
108 };
109
110 const togglePlaylistDropdown = (songId: number) => {
111 setOpenPlaylistDropdown((prev) => (prev === songId ? null : songId));
112 };
113
114 const handleAddToPlaylist = (songId: number, playlistName: string) => {
115 console.log(`Adding song ${songId} to ${playlistName}`);
116 // TODO: Implement actual API call
117 setOpenPlaylistDropdown(null);
118 };
119
120 const handleCreateNewPlaylist = (songId: number) => {
121 console.log(`Creating new playlist for song ${songId}`);
122 // TODO: Implement actual playlist creation
123 setOpenPlaylistDropdown(null);
124 };
125
[cb4a1da]126 const performSearch = async (query: string, category: SearchCategory) => {
127 if (!query.trim()) return;
128
129 setSearchLoading(true);
130 setHasSearched(true);
131 setActiveQuery(query);
132 setSearchCategory(category);
133
134 try {
135 let endpoint = "";
136 switch (category) {
137 case "songs":
138 endpoint = `/songs/search?q=${encodeURIComponent(query)}`;
139 break;
140 case "albums":
141 endpoint = `/albums/search?q=${encodeURIComponent(query)}`;
142 break;
143 case "artists":
144 endpoint = `/users/search?type=ARTIST&q=${encodeURIComponent(query)}`;
145 break;
146 case "users":
147 endpoint = `/users/search?type=LISTENER&q=${encodeURIComponent(query)}`;
148 break;
149 }
150
151 const response = await axiosInstance.get(endpoint);
152 setSearchResults(response.data);
153 } catch (error) {
154 console.error("Search error:", error);
155 setSearchResults([]);
156 } finally {
157 setSearchLoading(false);
158 }
159 };
160
161 const handleSearch = () => {
162 performSearch(searchInput, searchCategory);
163 };
164
165 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
166 if (e.key === "Enter") {
167 handleSearch();
168 }
169 };
170
171 const handleCategorySwitch = (category: SearchCategory) => {
172 performSearch(activeQuery, category);
173 };
174
175 const clearSearch = () => {
176 setSearchInput("");
177 setActiveQuery("");
178 setHasSearched(false);
179 setSearchResults([]);
180 };
181
182 const renderResults = () => {
183 if (searchLoading) {
184 return (
185 <div className="flex justify-center py-12">
186 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div>
187 </div>
188 );
189 }
190
191 if (searchResults.length === 0) {
192 return (
193 <div className="text-center py-12 text-gray-400">
194 <p className="text-lg">
195 No {searchCategory} found for "{activeQuery}"
196 </p>
197 </div>
198 );
199 }
200
201 return (
202 <div className="divide-y divide-white/5">
203 {searchResults.map((result, index) => {
204 switch (searchCategory) {
205 case "songs":
206 return (
207 <SongResult key={(result as Song).id} song={result as Song} />
208 );
209 case "albums":
210 return (
211 <AlbumResult
212 key={(result as Album).id}
213 album={result as Album}
214 />
215 );
216 case "artists":
217 return (
218 <UserResult
219 key={(result as BaseNonAdminUser).username ?? index}
220 user={result as BaseNonAdminUser}
221 label="Artist"
222 />
223 );
224 case "users":
225 return (
226 <UserResult
227 key={(result as BaseNonAdminUser).username ?? index}
228 user={result as BaseNonAdminUser}
229 label="User"
230 />
231 );
232 }
233 })}
234 </div>
235 );
236 };
237
[694fc25]238 return (
[cb4a1da]239 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white flex">
240 <div className="flex-1">
241 <div className="p-8">
242 <div className="max-w-7xl mx-auto">
243 {/* Search Bar */}
244 <div className="mb-8 flex flex-col md:flex-row gap-3">
245 <div className="flex flex-1 gap-0">
246 {/* Category Dropdown */}
247 <select
248 value={searchCategory}
249 onChange={(e) =>
250 setSearchCategory(e.target.value as SearchCategory)
251 }
252 className="bg-[#282828] border border-white/10 border-r-0 rounded-l-full py-2 px-4 text-white text-sm focus:outline-none focus:border-[#1db954] appearance-none cursor-pointer"
[694fc25]253 >
[cb4a1da]254 {CATEGORIES.map((cat) => (
255 <option key={cat.value} value={cat.value}>
256 {cat.label}
257 </option>
258 ))}
259 </select>
260
261 {/* Search Input */}
262 <input
263 type="text"
264 placeholder={`Search ${searchCategory}...`}
265 value={searchInput}
266 onChange={(e) => setSearchInput(e.target.value)}
267 onKeyDown={handleKeyDown}
268 className="flex-1 bg-[#282828] border border-white/10 border-r-0 py-2 px-4 text-white focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
269 />
[694fc25]270
[cb4a1da]271 {/* Search Button */}
272 <button
273 onClick={handleSearch}
274 className="bg-[#1db954] hover:bg-[#1ed760] text-black font-medium px-6 rounded-r-full transition-colors flex items-center gap-2"
275 >
276 <svg
277 className="w-5 h-5"
278 fill="none"
279 stroke="currentColor"
280 viewBox="0 0 24 24"
[694fc25]281 >
[cb4a1da]282 <path
283 strokeLinecap="round"
284 strokeLinejoin="round"
285 strokeWidth={2}
286 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
[694fc25]287 />
[cb4a1da]288 </svg>
289 <span className="hidden sm:inline">Search</span>
290 </button>
[694fc25]291 </div>
292 </div>
293
[cb4a1da]294 {/* Search Results Section */}
295 {hasSearched ? (
296 <div>
297 <div className="flex items-center justify-between mb-4">
298 <h2 className="text-2xl font-bold text-white">
299 Results for "{activeQuery}"
300 </h2>
301 <button
302 onClick={clearSearch}
303 className="text-sm text-gray-400 hover:text-white transition-colors"
[694fc25]304 >
[cb4a1da]305 Clear search
306 </button>
307 </div>
308
309 {/* Quick category switch buttons */}
310 <div className="flex gap-2 mb-6">
311 {CATEGORIES.map((cat) => (
312 <button
313 key={cat.value}
314 onClick={() => handleCategorySwitch(cat.value)}
315 className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
316 searchCategory === cat.value
317 ? "bg-[#1db954] text-black"
318 : "bg-white/10 text-white hover:bg-white/20"
319 }`}
320 >
321 {cat.label}
322 </button>
323 ))}
324 </div>
[694fc25]325
[cb4a1da]326 {/* Results list */}
327 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden">
328 {renderResults()}
329 </div>
[694fc25]330 </div>
331 ) : (
[cb4a1da]332 /* Default song grid */
333 <>
334 <div className="mb-8 border-b border-white/10 pb-6">
[694fc25]335 <h1 className="text-5xl font-bold mb-3 pb-1 bg-linear-to-r from-[#1db954] to-[#1ed760] bg-clip-text text-transparent">
336 Top Songs
337 </h1>
338 <p className="text-xl text-gray-400">
339 Listen to the newest tracks on FinkWave
340 </p>
341 </div>
[cb4a1da]342
343 {loading ? (
344 <div className="flex flex-col items-center justify-center min-h-[40vh] gap-6">
345 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div>
346 <p className="text-xl text-gray-400">Loading songs...</p>
347 </div>
348 ) : (
349 <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-8 py-4">
350 {songs.map((song) => (
351 <div
352 key={song.id}
[9c1dcc7]353 onClick={() => navigate(`/songs/${song.id}`)}
[cb4a1da]354 className="bg-[#282828] rounded-xl overflow-hidden cursor-pointer shadow-lg transition-all duration-300 ease-in-out hover:-translate-y-2 hover:shadow-2xl group"
355 >
356 <div className="relative w-full pt-[100%] overflow-hidden bg-[#181818]">
357 <img
358 src={song.cover || "/favicon.png"}
359 alt={song.title}
360 className="absolute top-0 left-0 w-full h-full object-cover"
361 onError={(e) => {
362 (e.target as HTMLImageElement).src =
363 "/favicon.png";
364 }}
365 />
366 <div className="absolute top-0 left-0 w-full h-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
[9c1dcc7]367 {song.link ? (
368 <button
369 onClick={(e) => {
370 e.stopPropagation();
371 play({
372 id: song.id,
373 title: song.title,
374 artist: song.releasedBy,
375 cover: song.cover,
376 embedUrl: toEmbedUrl(song.link!),
377 });
378 }}
379 className={`w-14 h-14 rounded-full flex items-center justify-center text-xl shadow-[0_4px_12px_rgba(29,185,84,0.5)] transition-all cursor-pointer ${
380 currentSong?.id === song.id
381 ? "bg-white text-[#1db954]"
382 : "bg-[#1db954] text-black hover:scale-105"
383 }`}
384 aria-label={
385 currentSong?.id === song.id
386 ? "Now playing"
387 : "Play song"
388 }
389 >
390 {currentSong?.id === song.id ? (
391 <svg
392 className="w-6 h-6"
393 fill="currentColor"
394 viewBox="0 0 24 24"
395 >
396 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
397 </svg>
398 ) : (
399 <svg
400 className="w-6 h-6"
401 fill="currentColor"
402 viewBox="0 0 24 24"
403 >
404 <path d="M8 5v14l11-7z" />
405 </svg>
406 )}
407 </button>
408 ) : (
409 <div className="w-14 h-14 rounded-full bg-[#1db954] flex items-center justify-center text-xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]">
410
411 </div>
412 )}
[694fc25]413 </div>
414 </div>
[b071fb4]415 <div className="p-4">
[cb4a1da]416 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap">
417 {song.title}
418 </h3>
[8ccb07e]419 {song.album && (
420 <p
421 onClick={(e) => {
422 e.stopPropagation();
423 if (song.albumId) {
424 navigate(`/collection/album/${song.albumId}`);
425 }
426 }}
427 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${
428 song.albumId
429 ? "hover:underline cursor-pointer hover:text-white"
430 : ""
431 }`}
432 >
433 {song.album}
434 </p>
435 )}
436 <p
437 onClick={(e) => {
438 e.stopPropagation();
439 if (song.artistUsername) {
440 navigate(`/users/${song.artistUsername}`);
441 }
442 }}
443 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${
444 song.artistUsername
445 ? "hover:underline cursor-pointer hover:text-white"
446 : ""
447 }`}
448 >
[cb4a1da]449 {song.releasedBy}
450 </p>
[b071fb4]451 <div className="flex items-center justify-between mt-4 pt-3 border-t border-white/10">
452 {/* Like button */}
453 <button
454 onClick={(e) => {
455 e.stopPropagation();
456 toggleLike(song.id);
457 }}
458 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer"
459 aria-label={
460 song.isLikedByCurrentUser
461 ? "Unlike song"
462 : "Like song"
463 }
464 >
465 {song.isLikedByCurrentUser ? (
466 <svg
467 className="w-6 h-6 fill-[#1db954]"
468 viewBox="0 0 24 24"
469 >
470 <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" />
471 </svg>
472 ) : (
473 <svg
474 className="w-6 h-6"
475 fill="none"
476 stroke="currentColor"
477 viewBox="0 0 24 24"
478 >
479 <path
480 strokeLinecap="round"
481 strokeLinejoin="round"
482 strokeWidth={2}
483 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"
484 />
485 </svg>
486 )}
487 </button>
488
489 {/* Add to playlist button */}
490 <div
491 className="relative"
492 ref={(el) => {
493 if (el)
494 playlistDropdownRefs.current[song.id] = el;
495 }}
496 >
497 <button
498 onClick={(e) => {
499 e.stopPropagation();
500 togglePlaylistDropdown(song.id);
501 }}
502 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer"
503 aria-label="Add to playlist"
504 >
505 <svg
506 className="w-6 h-6"
507 fill="none"
508 stroke="currentColor"
509 viewBox="0 0 24 24"
510 >
511 <path
512 strokeLinecap="round"
513 strokeLinejoin="round"
514 strokeWidth={2}
515 d="M12 4v16m8-8H4"
516 />
517 </svg>
518 </button>
519
520 {/* Playlist dropdown */}
521 {openPlaylistDropdown === song.id && (
522 <div className="absolute right-0 bottom-full mb-2 w-48 bg-gray-700 rounded-lg shadow-lg py-1 z-50">
523 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10">
524 Add to playlist
525 </div>
526 <button
527 onClick={(e) => {
528 e.stopPropagation();
529 handleAddToPlaylist(
530 song.id,
531 "Playlist 1",
532 );
533 }}
534 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors"
535 >
536 Playlist 1
537 </button>
538 <button
539 onClick={(e) => {
540 e.stopPropagation();
541 handleAddToPlaylist(
542 song.id,
543 "Playlist 2",
544 );
545 }}
546 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors"
547 >
548 Playlist 2
549 </button>
550 <button
551 onClick={(e) => {
552 e.stopPropagation();
553 handleCreateNewPlaylist(song.id);
554 }}
555 className="w-full text-left px-4 py-2 text-sm text-[#1db954] hover:bg-gray-600 transition-colors border-t border-white/10 flex items-center gap-2"
556 >
557 <svg
558 className="w-4 h-4"
559 fill="none"
560 stroke="currentColor"
561 viewBox="0 0 24 24"
562 >
563 <path
564 strokeLinecap="round"
565 strokeLinejoin="round"
566 strokeWidth={2}
567 d="M12 4v16m8-8H4"
568 />
569 </svg>
570 Create new playlist
571 </button>
572 </div>
573 )}
574 </div>
575 </div>
[cb4a1da]576 </div>
[694fc25]577 </div>
[cb4a1da]578 ))}
579 </div>
580 )}
581 </>
[694fc25]582 )}
583 </div>
584 </div>
585 </div>
[cb4a1da]586 </div>
[694fc25]587 );
[0c86e77]588};
589
590export default LandingPage;
Note: See TracBrowser for help on using the repository browser.