Changeset ce45c7a for frontend/src/pages/LandingPage.tsx
- Timestamp:
- 02/15/26 00:39:55 (5 months ago)
- Branches:
- main
- Children:
- c8baad1
- Parents:
- 85512ff
- File:
-
- 1 edited
-
frontend/src/pages/LandingPage.tsx (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/LandingPage.tsx
r85512ff rce45c7a 1 import { useEffect, use Ref, useState } from "react";1 import { useEffect, useState } from "react"; 2 2 import { useNavigate } from "react-router-dom"; 3 3 import axiosInstance, { baseURL } from "../api/axiosInstance"; 4 import PlaylistDropdown from "../components/PlaylistDropdown"; 4 5 import AlbumResult from "../components/search/AlbumResult"; 5 6 import SongResult from "../components/search/SongResult"; … … 7 8 import { usePlayer } from "../context/playerContext"; 8 9 import type { 9 Album,10 BaseNonAdminUser,11 SearchCategory,12 Song,10 Album, 11 BaseNonAdminUser, 12 SearchCategory, 13 Song, 13 14 } from "../utils/types"; 14 15 import { toEmbedUrl } from "../utils/utils"; 15 16 16 17 const 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" },18 { value: "songs", label: "Songs" }, 19 { value: "albums", label: "Albums" }, 20 { value: "artists", label: "Artists" }, 21 { value: "users", label: "Users" }, 21 22 ]; 22 23 23 24 const LandingPage = () => { 24 const navigate = useNavigate(); 25 const { play, currentSong } = usePlayer(); 26 const [songs, setSongs] = useState<Song[]>([]); 27 const [loading, setLoading] = useState<boolean>(true); 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); 36 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 45 useEffect(() => { 46 const fetchSongs = async () => { 47 try { 48 const response = await axiosInstance.get("/songs/top"); 49 setSongs(response.data); 50 } catch (error) { 51 console.error("Error fetching songs:", error); 52 } finally { 53 setLoading(false); 54 } 55 }; 56 fetchSongs(); 57 }, []); 58 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 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 219 return ( 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" 234 > 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 /> 251 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" 262 > 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" 268 /> 269 </svg> 270 <span className="hidden sm:inline">Search</span> 271 </button> 272 </div> 273 </div> 274 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" 285 > 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> 306 307 {/* Results list */} 308 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden"> 309 {renderResults()} 310 </div> 311 </div> 312 ) : ( 313 /* Default song grid */ 314 <> 315 <div className="mb-8 border-b border-white/10 pb-6"> 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> 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} 334 onClick={() => navigate(`/songs/${song.id}`)} 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 339 src={ 340 song.cover 341 ? `${baseURL}/${song.cover}` 342 : "/favicon.png" 343 } 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"> 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 )} 398 </div> 399 </div> 400 <div className="p-4"> 401 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap"> 402 {song.title} 403 </h3> 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 > 434 {song.releasedBy} 435 </p> 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> 561 </div> 562 </div> 563 ))} 564 </div> 565 )} 566 </> 567 )} 568 </div> 569 </div> 570 </div> 571 </div> 572 ); 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 ); 573 509 }; 574 510
Note:
See TracChangeset
for help on using the changeset viewer.
