Changeset 947fcaa for frontend/src/pages/LandingPage.tsx
- Timestamp:
- 02/21/26 19:19:51 (5 months ago)
- Branches:
- main
- Parents:
- 765e166
- File:
-
- 1 edited
-
frontend/src/pages/LandingPage.tsx (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/pages/LandingPage.tsx
r765e166 r947fcaa 6 6 import SongResult from "../components/search/SongResult"; 7 7 import UserResult from "../components/search/UserResult"; 8 import { useAuth } from "../context/authContext"; 8 9 import { usePlayer } from "../context/playerContext"; 9 10 import type { 10 Album,11 BaseNonAdminUser,12 SearchCategory,13 Song,11 Album, 12 BaseNonAdminUser, 13 SearchCategory, 14 Song, 14 15 } from "../utils/types"; 15 16 import { toEmbedUrl } from "../utils/utils"; 16 import { useAuth } from "../context/authContext";17 17 18 18 const 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" },19 { value: "songs", label: "Songs" }, 20 { value: "albums", label: "Albums" }, 21 { value: "artists", label: "Artists" }, 22 { value: "users", label: "Users" }, 23 23 ]; 24 24 25 25 const 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 | null41 >(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 === songId63 ? { ...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 <AlbumResult166 key={(result as Album).id}167 album={result as Album}168 />169 );170 case "artists":171 return (172 <UserResult173 key={(result as BaseNonAdminUser).username ?? index}174 user={result as BaseNonAdminUser}175 label="Artist"176 />177 );178 case "users":179 return (180 <UserResult181 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 <select202 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 <input217 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 <button227 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"229 >230 <svg231 className="w-5 h-5"232 fill="none"233 stroke="currentColor"234 viewBox="0 0 24 24"235 >236 <path237 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 <button256 onClick={clearSearch}257 className="text-sm text-gray-400 hover:text-white transition-colors"258 >259 Clear search260 </button>261 </div>262 263 {/* Quick category switch buttons */}264 <div className="flex gap-2 mb-6">265 {CATEGORIES.map((cat) => (266 <button267 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.value271 ? "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 Songs291 </h1>292 <p className="text-xl text-gray-400">293 Listen to the newest tracks on FinkWave294 </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 <div306 key={song.id}307 onClick={() => navigate(`/songs/${song.id}`)}308 onMouseEnter={() => {309 if (310 openPlaylistDropdown !== null &&311 openPlaylistDropdown !== song.id312 ) {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 <img320 src={321 song.cover322 ? `${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 <button335 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.id347 ? "bg-white text-[#1db954]"348 : "bg-[#1db954] text-black hover:scale-105"349 }`}350 aria-label={351 currentSong?.id === song.id352 ? "Now playing"353 : "Play song"354 }355 >356 {currentSong?.id === song.id ? (357 <svg358 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 <svg366 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 <p387 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.albumId395 ? "hover:underline cursor-pointer hover:text-white"396 : ""397 }`}398 >399 {song.album}400 </p>401 )}402 <p403 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.artistUsername411 ? "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 <button421 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.isLikedByCurrentUser428 ? "Unlike song"429 : "Like song"430 }431 >432 {song.isLikedByCurrentUser ? (433 <svg434 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 <svg441 className="w-6 h-6"442 fill="none"443 stroke="currentColor"444 viewBox="0 0 24 24"445 >446 <path447 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 <button459 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 <svg467 className="w-6 h-6"468 fill="none"469 stroke="currentColor"470 viewBox="0 0 24 24"471 >472 <path473 strokeLinecap="round"474 strokeLinejoin="round"475 strokeWidth={2}476 d="M12 4v16m8-8H4"477 />478 </svg>479 </button>480 481 <PlaylistDropdown482 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 );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 502 }; 503 503
Note:
See TracChangeset
for help on using the changeset viewer.
