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

main
Last change on this file since c8baad1 was c8baad1, checked in by Dimitar Arsov <dimitararsov04@…>, 5 months ago

create playlists from sidebar

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