source: frontend/src/pages/LandingPage.tsx@ 591919d

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

add album to songs on landing page

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