source: frontend/src/pages/LandingPage.tsx@ 8675f75

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

add experimental video miniplayer

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