source: frontend/src/pages/LandingPage.tsx@ 1579b4f

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

refactor artist, collection, listener components to have similar style as other components; refactor some dto-s

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