source: frontend/src/pages/LandingPage.tsx@ f5bc95e

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

fix music cover ui bug

  • Property mode set to 100644
File size: 18.3 KB
RevLine 
[b071fb4]1import { useEffect, useRef, useState } from "react";
[9c1dcc7]2import { useNavigate } from "react-router-dom";
[f5bc95e]3import axiosInstance, { baseURL } 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";
[1579b4f]14import { toEmbedUrl } from "../utils/utils";
[9c1dcc7]15
[cb4a1da]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];
[694fc25]22
[0c86e77]23const LandingPage = () => {
[9c1dcc7]24 const navigate = useNavigate();
25 const { play, currentSong } = usePlayer();
[694fc25]26 const [songs, setSongs] = useState<Song[]>([]);
27 const [loading, setLoading] = useState<boolean>(true);
[cb4a1da]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);
[694fc25]36
[b071fb4]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
[694fc25]45 useEffect(() => {
46 const fetchSongs = async () => {
47 try {
[cb4a1da]48 const response = await axiosInstance.get("/songs/top");
49 setSongs(response.data);
[694fc25]50 } catch (error) {
51 console.error("Error fetching songs:", error);
52 } finally {
53 setLoading(false);
54 }
55 };
56 fetchSongs();
57 }, []);
58
[b071fb4]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
[cb4a1da]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
[694fc25]219 return (
[cb4a1da]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"
[694fc25]234 >
[cb4a1da]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 />
[694fc25]251
[cb4a1da]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"
[694fc25]262 >
[cb4a1da]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"
[694fc25]268 />
[cb4a1da]269 </svg>
270 <span className="hidden sm:inline">Search</span>
271 </button>
[694fc25]272 </div>
273 </div>
274
[cb4a1da]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"
[694fc25]285 >
[cb4a1da]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>
[694fc25]306
[cb4a1da]307 {/* Results list */}
308 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden">
309 {renderResults()}
310 </div>
[694fc25]311 </div>
312 ) : (
[cb4a1da]313 /* Default song grid */
314 <>
315 <div className="mb-8 border-b border-white/10 pb-6">
[694fc25]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>
[cb4a1da]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}
[9c1dcc7]334 onClick={() => navigate(`/songs/${song.id}`)}
[cb4a1da]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
[f5bc95e]339 src={
340 song.cover
341 ? `${baseURL}/${song.cover}`
342 : "/favicon.png"
343 }
[cb4a1da]344 alt={song.title}
345 className="absolute top-0 left-0 w-full h-full object-cover"
346 onError={(e) => {
347 (e.target as HTMLImageElement).src =
348 "/favicon.png";
349 }}
350 />
351 <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]352 {song.link ? (
353 <button
354 onClick={(e) => {
355 e.stopPropagation();
356 play({
357 id: song.id,
358 title: song.title,
359 artist: song.releasedBy,
360 cover: song.cover,
361 embedUrl: toEmbedUrl(song.link!),
362 });
363 }}
364 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 ${
365 currentSong?.id === song.id
366 ? "bg-white text-[#1db954]"
367 : "bg-[#1db954] text-black hover:scale-105"
368 }`}
369 aria-label={
370 currentSong?.id === song.id
371 ? "Now playing"
372 : "Play song"
373 }
374 >
375 {currentSong?.id === song.id ? (
376 <svg
377 className="w-6 h-6"
378 fill="currentColor"
379 viewBox="0 0 24 24"
380 >
381 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
382 </svg>
383 ) : (
384 <svg
385 className="w-6 h-6"
386 fill="currentColor"
387 viewBox="0 0 24 24"
388 >
389 <path d="M8 5v14l11-7z" />
390 </svg>
391 )}
392 </button>
393 ) : (
394 <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)]">
395
396 </div>
397 )}
[694fc25]398 </div>
399 </div>
[b071fb4]400 <div className="p-4">
[cb4a1da]401 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap">
402 {song.title}
403 </h3>
[8ccb07e]404 {song.album && (
405 <p
406 onClick={(e) => {
407 e.stopPropagation();
408 if (song.albumId) {
409 navigate(`/collection/album/${song.albumId}`);
410 }
411 }}
412 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${
413 song.albumId
414 ? "hover:underline cursor-pointer hover:text-white"
415 : ""
416 }`}
417 >
418 {song.album}
419 </p>
420 )}
421 <p
422 onClick={(e) => {
423 e.stopPropagation();
424 if (song.artistUsername) {
425 navigate(`/users/${song.artistUsername}`);
426 }
427 }}
428 className={`text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap ${
429 song.artistUsername
430 ? "hover:underline cursor-pointer hover:text-white"
431 : ""
432 }`}
433 >
[cb4a1da]434 {song.releasedBy}
435 </p>
[b071fb4]436 <div className="flex items-center justify-between mt-4 pt-3 border-t border-white/10">
437 {/* Like button */}
438 <button
439 onClick={(e) => {
440 e.stopPropagation();
441 toggleLike(song.id);
442 }}
443 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer"
444 aria-label={
445 song.isLikedByCurrentUser
446 ? "Unlike song"
447 : "Like song"
448 }
449 >
450 {song.isLikedByCurrentUser ? (
451 <svg
452 className="w-6 h-6 fill-[#1db954]"
453 viewBox="0 0 24 24"
454 >
455 <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" />
456 </svg>
457 ) : (
458 <svg
459 className="w-6 h-6"
460 fill="none"
461 stroke="currentColor"
462 viewBox="0 0 24 24"
463 >
464 <path
465 strokeLinecap="round"
466 strokeLinejoin="round"
467 strokeWidth={2}
468 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"
469 />
470 </svg>
471 )}
472 </button>
473
474 {/* Add to playlist button */}
475 <div
476 className="relative"
477 ref={(el) => {
478 if (el)
479 playlistDropdownRefs.current[song.id] = el;
480 }}
481 >
482 <button
483 onClick={(e) => {
484 e.stopPropagation();
485 togglePlaylistDropdown(song.id);
486 }}
487 className="text-gray-400 hover:text-[#1db954] transition-colors cursor-pointer"
488 aria-label="Add to playlist"
489 >
490 <svg
491 className="w-6 h-6"
492 fill="none"
493 stroke="currentColor"
494 viewBox="0 0 24 24"
495 >
496 <path
497 strokeLinecap="round"
498 strokeLinejoin="round"
499 strokeWidth={2}
500 d="M12 4v16m8-8H4"
501 />
502 </svg>
503 </button>
504
505 {/* Playlist dropdown */}
506 {openPlaylistDropdown === song.id && (
507 <div className="absolute right-0 bottom-full mb-2 w-48 bg-gray-700 rounded-lg shadow-lg py-1 z-50">
508 <div className="px-3 py-2 text-xs text-gray-400 border-b border-white/10">
509 Add to playlist
510 </div>
511 <button
512 onClick={(e) => {
513 e.stopPropagation();
514 handleAddToPlaylist(
515 song.id,
516 "Playlist 1",
517 );
518 }}
519 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors"
520 >
521 Playlist 1
522 </button>
523 <button
524 onClick={(e) => {
525 e.stopPropagation();
526 handleAddToPlaylist(
527 song.id,
528 "Playlist 2",
529 );
530 }}
531 className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-600 transition-colors"
532 >
533 Playlist 2
534 </button>
535 <button
536 onClick={(e) => {
537 e.stopPropagation();
538 handleCreateNewPlaylist(song.id);
539 }}
540 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"
541 >
542 <svg
543 className="w-4 h-4"
544 fill="none"
545 stroke="currentColor"
546 viewBox="0 0 24 24"
547 >
548 <path
549 strokeLinecap="round"
550 strokeLinejoin="round"
551 strokeWidth={2}
552 d="M12 4v16m8-8H4"
553 />
554 </svg>
555 Create new playlist
556 </button>
557 </div>
558 )}
559 </div>
560 </div>
[cb4a1da]561 </div>
[694fc25]562 </div>
[cb4a1da]563 ))}
564 </div>
565 )}
566 </>
[694fc25]567 )}
568 </div>
569 </div>
570 </div>
[cb4a1da]571 </div>
[694fc25]572 );
[0c86e77]573};
574
575export default LandingPage;
Note: See TracBrowser for help on using the repository browser.