source: frontend/src/pages/LandingPage.tsx

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

add cursor pointer to buttons

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