Changeset 947fcaa for frontend/src/pages
- Timestamp:
- 02/21/26 19:19:51 (5 months ago)
- Branches:
- main
- Parents:
- 765e166
- Location:
- frontend/src/pages
- Files:
-
- 3 edited
-
LandingPage.tsx (modified) (1 diff)
-
Nav.tsx (modified) (1 diff)
-
SongDetail.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 -
frontend/src/pages/Nav.tsx
r765e166 r947fcaa 56 56 <button 57 57 onClick={onToggleSidebar} 58 className="text-white hover:text-[#1db954] transition-colors p-2 "58 className="text-white hover:text-[#1db954] transition-colors p-2 cursor-pointer" 59 59 aria-label="Toggle sidebar" 60 60 > -
frontend/src/pages/SongDetail.tsx
r765e166 r947fcaa 10 10 11 11 const ROLE_LABELS: Record<string, string> = { 12 MAIN_VOCAL: "Main Vocal",13 FEATURED: "Featured",14 PRODUCER: "Producer",15 SONGWRITER: "Songwriter",16 COMPOSER: "Composer",17 MIXER: "Mixer",18 ENGINEER: "Engineer",12 MAIN_VOCAL: "Main Vocal", 13 FEATURED: "Featured", 14 PRODUCER: "Producer", 15 SONGWRITER: "Songwriter", 16 COMPOSER: "Composer", 17 MIXER: "Mixer", 18 ENGINEER: "Engineer", 19 19 }; 20 20 21 21 const formatRole = (role: string): string => { 22 return ROLE_LABELS[role] || role.replace(/_/g, " ");22 return ROLE_LABELS[role] || role.replace(/_/g, " "); 23 23 }; 24 24 25 25 const renderStars = (grade: number) => { 26 return (27 <div className="flex gap-0.5">28 {[1, 2, 3, 4, 5].map((star) => (29 <svg30 key={star}31 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`}32 fill="currentColor"33 viewBox="0 0 20 20"34 >35 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />36 </svg>37 ))}38 </div>39 );26 return ( 27 <div className="flex gap-0.5"> 28 {[1, 2, 3, 4, 5].map((star) => ( 29 <svg 30 key={star} 31 className={`w-4 h-4 ${star <= grade ? "text-yellow-400" : "text-gray-600"}`} 32 fill="currentColor" 33 viewBox="0 0 20 20" 34 > 35 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 36 </svg> 37 ))} 38 </div> 39 ); 40 40 }; 41 41 42 42 const SongDetail = () => { 43 const { id } = useParams<{ id: string }>();44 const { user } = useAuth();45 const { play, currentSong } = usePlayer();46 const [song, setSong] = useState<SongDetailType | null>(null);47 const [loading, setLoading] = useState(true);48 const [error, setError] = useState<string | null>(null);49 const [showReviewModal, setShowReviewModal] = useState(false);50 const [reviewRating, setReviewRating] = useState(0);51 const [reviewComment, setReviewComment] = useState("");52 const [hoverRating, setHoverRating] = useState(0);53 const [showPlaylistDropdown, setShowPlaylistDropdown] = useState(false);54 console.log(user);55 useEffect(() => {56 const fetchSong = async () => {57 try {58 setLoading(true);59 const response = await axiosInstance.get(`/songs/${id}/details`);60 setSong(response.data);61 } catch (err) {62 console.error("Error fetching song details:", err);63 setError("Failed to load song details.");64 } finally {65 setLoading(false);66 }67 };68 if (id) fetchSong();69 }, [id]);70 71 const handleSubmitReview = async () => {72 if (reviewRating === 0) {73 // todo: replace with toast74 alert("Please select a rating");75 return;76 }77 try {78 await axiosInstance.post(`reviews/${song?.id}`, {79 grade: reviewRating,80 comment: reviewComment,81 });82 const response = await axiosInstance.get(`/songs/${id}/details`);83 setSong(response.data);84 } catch (err) {85 // todo: replace with toast86 console.error("Error submitting review:", err);87 } finally {88 setShowReviewModal(false);89 setReviewRating(0);90 setReviewComment("");91 setHoverRating(0);92 }93 };94 95 const deleteReview = async () => {96 try {97 await axiosInstance.delete(`reviews/${song?.id}`);98 const response = await axiosInstance.get(`/songs/${id}/details`);99 setSong(response.data);100 } catch (err) {101 toast.error("Failed to delete review");102 console.error("Error deleting review:", err);103 }104 };105 106 const toggleLike = async () => {107 if (!song) return;108 try {109 await axiosInstance.post(`/musical-entity/${song.id}/like`);110 setSong((prev) =>111 prev112 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser }113 : prev,114 );115 } catch (err) {116 toast.error("Failed to toggle like");117 console.error("Error toggling like:", err);118 }119 };120 121 if (loading) {122 return (123 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">124 <div className="flex flex-col items-center gap-4">125 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />126 <p className="text-gray-400 text-lg">Loading song…</p>127 </div>128 </div>129 );130 }131 132 if (error || !song) {133 return (134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">135 <div className="text-center">136 <p className="text-red-400 text-xl mb-4">137 {error ?? "Song not found"}138 </p>139 <Link to="/" className="text-[#1db954] hover:underline text-sm">140 ← Back to Home141 </Link>142 </div>143 </div>144 );145 }146 147 const otherContributors = song.contributions.filter(148 (c) => c.artistName !== song.releasedBy,149 );150 151 const avgRating =152 song.reviews.length > 0153 ? (154 song.reviews.reduce((sum, r) => sum + r.grade, 0) /155 song.reviews.length156 ).toFixed(1)157 : null;158 159 return (160 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">161 <div className="max-w-5xl mx-auto px-6 py-10">162 {/* Back link */}163 <Link164 to="/"165 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"166 >167 <svg168 className="w-4 h-4"169 fill="none"170 stroke="currentColor"171 viewBox="0 0 24 24"172 >173 <path174 strokeLinecap="round"175 strokeLinejoin="round"176 strokeWidth={2}177 d="M15 19l-7-7 7-7"178 />179 </svg>180 Back to Home181 </Link>182 183 {/* Hero section */}184 <div className="flex flex-col md:flex-row gap-8 mb-10">185 {/* Cover art */}186 <div className="w-full md:w-72 shrink-0">187 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">188 <img189 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}190 alt={song.title}191 className="absolute inset-0 w-full h-full object-cover"192 onError={(e) => {193 (e.target as HTMLImageElement).src = "/favicon.png";194 }}195 />196 </div>197 </div>198 199 {/* Song info */}200 <div className="flex flex-col justify-end gap-3 min-w-0">201 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">202 {song.genre} • Song203 </span>204 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">205 {song.title}206 </h1>207 208 {/* Main artist */}209 <p className="text-xl text-gray-300 font-semibold">210 {song.releasedBy}211 </p>212 213 {/* Album */}214 {song.album && (215 <p className="text-sm text-gray-500">216 Album: <span className="text-gray-300">{song.album}</span>217 </p>218 )}219 220 {/* Rating summary */}221 {avgRating && (222 <div className="flex items-center gap-2 mt-1">223 {renderStars(Math.round(Number(avgRating)))}224 <span className="text-sm text-gray-400">225 {avgRating} · {song.reviews.length}{" "}226 {song.reviews.length === 1 ? "review" : "reviews"}227 </span>228 </div>229 )}230 231 {/* Action buttons */}232 <div className="flex items-center gap-3 mt-4">233 {/* Play button */}234 {song.link && (235 <button236 onClick={() =>237 play({238 id: song.id,239 title: song.title,240 artist: song.releasedBy,241 cover: song.cover,242 embedUrl: toEmbedUrl(song.link!),243 })244 }245 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${246 currentSong?.id === song.id247 ? "bg-white text-[#1db954]"248 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105"249 }`}250 >251 {currentSong?.id === song.id ? (252 <>253 <svg254 className="w-5 h-5"255 fill="currentColor"256 viewBox="0 0 24 24"257 >258 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />259 </svg>260 Now Playing261 </>262 ) : (263 <>264 <svg265 className="w-5 h-5"266 fill="currentColor"267 viewBox="0 0 24 24"268 >269 <path d="M8 5v14l11-7z" />270 </svg>271 Play Song272 </>273 )}274 </button>275 )}276 277 {user && (278 <>279 <button280 onClick={toggleLike}281 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${282 song.isLikedByCurrentUser283 ? "bg-[#1db954] text-black"284 : "bg-white/10 text-white hover:bg-white/20"285 }`}286 >287 {song.isLikedByCurrentUser ? (288 <svg289 className="w-5 h-5"290 fill="currentColor"291 viewBox="0 0 24 24"292 >293 <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" />294 </svg>295 ) : (296 <svg297 className="w-5 h-5"298 fill="none"299 stroke="currentColor"300 viewBox="0 0 24 24"301 >302 <path303 strokeLinecap="round"304 strokeLinejoin="round"305 strokeWidth={2}306 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"307 />308 </svg>309 )}310 {song.isLikedByCurrentUser ? "Liked" : "Like"}311 </button>312 313 {/* Add to Playlist button */}314 <div className="relative">315 <button316 onClick={() => setShowPlaylistDropdown((prev) => !prev)}317 className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold bg-white/10 text-white hover:bg-white/20 transition-colors cursor-pointer"318 >319 <svg320 className="w-5 h-5"321 fill="none"322 stroke="currentColor"323 viewBox="0 0 24 24"324 >325 <path326 strokeLinecap="round"327 strokeLinejoin="round"328 strokeWidth={2}329 d="M12 4v16m8-8H4"330 />331 </svg>332 Add to Playlist333 </button>334 335 <PlaylistDropdown336 songId={song.id}337 isOpen={showPlaylistDropdown}338 onClose={() => setShowPlaylistDropdown(false)}339 direction="below"340 />341 </div>342 </>343 )}344 </div>345 </div>346 </div>347 348 {/* Credits / Contributions */}349 {song.contributions.length > 0 && (350 <section className="mb-10">351 <h2 className="text-2xl font-bold mb-4">Credits</h2>352 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5">353 {/* Main artist first */}354 {song.contributions355 .filter((c) => c.artistName === song.releasedBy)356 .map((c, i) => (357 <div358 key={`main-${i}`}359 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"360 >361 <div className="flex items-center gap-3">362 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm">363 {c.artistName.charAt(0).toUpperCase()}364 </div>365 <div>366 <p className="text-white font-semibold text-lg">367 {c.artistName}368 </p>369 </div>370 </div>371 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full">372 {formatRole(c.role)}373 </span>374 </div>375 ))}376 377 {/* Other contributors */}378 {otherContributors.map((c, i) => (379 <div380 key={`contrib-${i}`}381 className="flex items-center justify-between py-3 first:pt-0 last:pb-0"382 >383 <div className="flex items-center gap-3">384 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm">385 {c.artistName.charAt(0).toUpperCase()}386 </div>387 <p className="text-gray-300 font-medium">{c.artistName}</p>388 </div>389 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full">390 {formatRole(c.role)}391 </span>392 </div>393 ))}394 </div>395 </section>396 )}397 398 {/* Reviews */}399 <section>400 <div className="flex items-center justify-between mb-4">401 <h2 className="text-2xl font-bold">402 Reviews403 {song.reviews.length > 0 && (404 <span className="text-base font-normal text-gray-500 ml-2">405 ({song.reviews.length})406 </span>407 )}408 </h2>409 {user != null && (410 <button411 onClick={() => setShowReviewModal(true)}412 className="flex items-center gap-2 bg-[#1db954] hover:bg-[#1ed760] text-black text-sm font-semibold px-4 py-2 rounded-full transition-colors"413 >414 <svg415 className="w-4 h-4"416 fill="none"417 stroke="currentColor"418 viewBox="0 0 24 24"419 >420 <path421 strokeLinecap="round"422 strokeLinejoin="round"423 strokeWidth={2}424 d="M12 4v16m8-8H4"425 />426 </svg>427 Add Review428 </button>429 )}430 </div>431 432 {song.reviews.length === 0 ? (433 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center">434 <p className="text-gray-500 text-lg">No reviews yet.</p>435 <p className="text-gray-600 text-sm mt-1">436 Be the first to share your thoughts!437 </p>438 </div>439 ) : (440 <div className="space-y-4">441 {song.reviews.map((review) => (442 <div443 key={`${review.id.listenerId}-${review.id.musicalEntityId}`}444 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors"445 >446 <div className="flex items-start justify-between mb-2">447 <div className="flex items-center gap-3">448 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center">449 <span className="text-blue-400 font-semibold text-sm">450 {review.author.charAt(0).toUpperCase()}451 </span>452 </div>453 <div>454 <p className="text-white font-medium">455 {review.author}456 </p>457 {renderStars(review.grade)}458 </div>459 </div>460 {user?.username === review.authorUsername && (461 <button462 onClick={deleteReview}463 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10"464 title="Delete review"465 >466 <svg467 className="w-5 h-5"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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"477 />478 </svg>479 </button>480 )}481 </div>482 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12">483 {review.comment}484 </p>485 </div>486 ))}487 </div>488 )}489 </section>490 </div>491 492 {/* Review Modal */}493 {showReviewModal && (494 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">495 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl">496 <div className="flex items-center justify-between mb-6">497 <h3 className="text-2xl font-bold">Add Review</h3>498 <button499 onClick={() => {500 setShowReviewModal(false);501 setReviewRating(0);502 setReviewComment("");503 setHoverRating(0);504 }}505 className="text-gray-400 hover:text-white transition-colors"506 >507 <svg508 className="w-6 h-6"509 fill="none"510 stroke="currentColor"511 viewBox="0 0 24 24"512 >513 <path514 strokeLinecap="round"515 strokeLinejoin="round"516 strokeWidth={2}517 d="M6 18L18 6M6 6l12 12"518 />519 </svg>520 </button>521 </div>522 523 {/* Star Rating */}524 <div className="mb-6">525 <label className="block text-sm font-medium mb-3">526 Rating <span className="text-red-400">*</span>527 </label>528 <div className="flex gap-2">529 {[1, 2, 3, 4, 5].map((star) => (530 <button531 key={star}532 onClick={() => setReviewRating(star)}533 onMouseEnter={() => setHoverRating(star)}534 onMouseLeave={() => setHoverRating(0)}535 className="transition-transform hover:scale-110"536 >537 <svg538 className={`w-10 h-10 ${539 star <= (hoverRating || reviewRating)540 ? "text-yellow-400"541 : "text-gray-600"542 }`}543 fill="currentColor"544 viewBox="0 0 20 20"545 >546 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />547 </svg>548 </button>549 ))}550 </div>551 </div>552 553 {/* Comment */}554 <div className="mb-6">555 <label className="block text-sm font-medium mb-2">556 Comment <span className="text-gray-500">(optional)</span>557 </label>558 <textarea559 value={reviewComment}560 onChange={(e) => setReviewComment(e.target.value)}561 placeholder="Share your thoughts about this song..."562 rows={4}563 className="w-full bg-[#0f0f1e] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] transition-colors resize-none"564 />565 </div>566 567 {/* Actions */}568 <div className="flex gap-3 justify-end">569 <button570 onClick={() => {571 setShowReviewModal(false);572 setReviewRating(0);573 setReviewComment("");574 setHoverRating(0);575 }}576 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors"577 >578 Cancel579 </button>580 <button581 onClick={handleSubmitReview}582 disabled={reviewRating === 0}583 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-[#1db954] hover:bg-[#1ed760] text-black transition-colors disabled:opacity-50 disabled:cursor-not-allowed"584 >585 Submit586 </button>587 </div>588 </div>589 </div>590 )}591 </div>592 );43 const { id } = useParams<{ id: string }>(); 44 const { user } = useAuth(); 45 const { play, currentSong } = usePlayer(); 46 const [song, setSong] = useState<SongDetailType | null>(null); 47 const [loading, setLoading] = useState(true); 48 const [error, setError] = useState<string | null>(null); 49 const [showReviewModal, setShowReviewModal] = useState(false); 50 const [reviewRating, setReviewRating] = useState(0); 51 const [reviewComment, setReviewComment] = useState(""); 52 const [hoverRating, setHoverRating] = useState(0); 53 const [showPlaylistDropdown, setShowPlaylistDropdown] = useState(false); 54 console.log(user); 55 useEffect(() => { 56 const fetchSong = async () => { 57 try { 58 setLoading(true); 59 const response = await axiosInstance.get(`/songs/${id}/details`); 60 setSong(response.data); 61 } catch (err) { 62 console.error("Error fetching song details:", err); 63 setError("Failed to load song details."); 64 } finally { 65 setLoading(false); 66 } 67 }; 68 if (id) fetchSong(); 69 }, [id]); 70 71 const handleSubmitReview = async () => { 72 if (reviewRating === 0) { 73 // todo: replace with toast 74 alert("Please select a rating"); 75 return; 76 } 77 try { 78 await axiosInstance.post(`reviews/${song?.id}`, { 79 grade: reviewRating, 80 comment: reviewComment, 81 }); 82 const response = await axiosInstance.get(`/songs/${id}/details`); 83 setSong(response.data); 84 } catch (err) { 85 // todo: replace with toast 86 console.error("Error submitting review:", err); 87 } finally { 88 setShowReviewModal(false); 89 setReviewRating(0); 90 setReviewComment(""); 91 setHoverRating(0); 92 } 93 }; 94 95 const deleteReview = async () => { 96 try { 97 await axiosInstance.delete(`reviews/${song?.id}`); 98 const response = await axiosInstance.get(`/songs/${id}/details`); 99 setSong(response.data); 100 } catch (err) { 101 toast.error("Failed to delete review"); 102 console.error("Error deleting review:", err); 103 } 104 }; 105 106 const toggleLike = async () => { 107 if (!song) return; 108 try { 109 await axiosInstance.post(`/musical-entity/${song.id}/like`); 110 setSong((prev) => 111 prev 112 ? { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser } 113 : prev, 114 ); 115 } catch (err) { 116 toast.error("Failed to toggle like"); 117 console.error("Error toggling like:", err); 118 } 119 }; 120 121 if (loading) { 122 return ( 123 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 124 <div className="flex flex-col items-center gap-4"> 125 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" /> 126 <p className="text-gray-400 text-lg">Loading song…</p> 127 </div> 128 </div> 129 ); 130 } 131 132 if (error || !song) { 133 return ( 134 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center"> 135 <div className="text-center"> 136 <p className="text-red-400 text-xl mb-4"> 137 {error ?? "Song not found"} 138 </p> 139 <Link to="/" className="text-[#1db954] hover:underline text-sm"> 140 ← Back to Home 141 </Link> 142 </div> 143 </div> 144 ); 145 } 146 147 const otherContributors = song.contributions.filter( 148 (c) => c.artistName !== song.releasedBy, 149 ); 150 151 const avgRating = 152 song.reviews.length > 0 153 ? ( 154 song.reviews.reduce((sum, r) => sum + r.grade, 0) / 155 song.reviews.length 156 ).toFixed(1) 157 : null; 158 159 return ( 160 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white"> 161 <div className="max-w-5xl mx-auto px-6 py-10"> 162 {/* Back link */} 163 <Link 164 to="/" 165 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors" 166 > 167 <svg 168 className="w-4 h-4" 169 fill="none" 170 stroke="currentColor" 171 viewBox="0 0 24 24" 172 > 173 <path 174 strokeLinecap="round" 175 strokeLinejoin="round" 176 strokeWidth={2} 177 d="M15 19l-7-7 7-7" 178 /> 179 </svg> 180 Back to Home 181 </Link> 182 183 {/* Hero section */} 184 <div className="flex flex-col md:flex-row gap-8 mb-10"> 185 {/* Cover art */} 186 <div className="w-full md:w-72 shrink-0"> 187 <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]"> 188 <img 189 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"} 190 alt={song.title} 191 className="absolute inset-0 w-full h-full object-cover" 192 onError={(e) => { 193 (e.target as HTMLImageElement).src = "/favicon.png"; 194 }} 195 /> 196 </div> 197 </div> 198 199 {/* Song info */} 200 <div className="flex flex-col justify-end gap-3 min-w-0"> 201 <span className="text-xs uppercase tracking-widest text-gray-400 font-medium"> 202 {song.genre} • Song 203 </span> 204 <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate"> 205 {song.title} 206 </h1> 207 208 {/* Main artist */} 209 <p className="text-xl text-gray-300 font-semibold"> 210 {song.releasedBy} 211 </p> 212 213 {/* Album */} 214 {song.album && ( 215 <p className="text-sm text-gray-500"> 216 Album: <span className="text-gray-300">{song.album}</span> 217 </p> 218 )} 219 220 {/* Rating summary */} 221 {avgRating && ( 222 <div className="flex items-center gap-2 mt-1"> 223 {renderStars(Math.round(Number(avgRating)))} 224 <span className="text-sm text-gray-400"> 225 {avgRating} · {song.reviews.length}{" "} 226 {song.reviews.length === 1 ? "review" : "reviews"} 227 </span> 228 </div> 229 )} 230 231 {/* Action buttons */} 232 <div className="flex items-center gap-3 mt-4"> 233 {/* Play button */} 234 {song.link && ( 235 <button 236 onClick={() => 237 play({ 238 id: song.id, 239 title: song.title, 240 artist: song.releasedBy, 241 cover: song.cover, 242 embedUrl: toEmbedUrl(song.link!), 243 }) 244 } 245 className={`flex items-center gap-2 px-6 py-3 rounded-full text-sm font-semibold transition-all cursor-pointer ${ 246 currentSong?.id === song.id 247 ? "bg-white text-[#1db954]" 248 : "bg-[#1db954] text-black hover:bg-[#1ed760] hover:scale-105" 249 }`} 250 > 251 {currentSong?.id === song.id ? ( 252 <> 253 <svg 254 className="w-5 h-5" 255 fill="currentColor" 256 viewBox="0 0 24 24" 257 > 258 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /> 259 </svg> 260 Now Playing 261 </> 262 ) : ( 263 <> 264 <svg 265 className="w-5 h-5" 266 fill="currentColor" 267 viewBox="0 0 24 24" 268 > 269 <path d="M8 5v14l11-7z" /> 270 </svg> 271 Play Song 272 </> 273 )} 274 </button> 275 )} 276 277 {user && ( 278 <> 279 <button 280 onClick={toggleLike} 281 className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${ 282 song.isLikedByCurrentUser 283 ? "bg-[#1db954] text-black" 284 : "bg-white/10 text-white hover:bg-white/20" 285 }`} 286 > 287 {song.isLikedByCurrentUser ? ( 288 <svg 289 className="w-5 h-5" 290 fill="currentColor" 291 viewBox="0 0 24 24" 292 > 293 <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" /> 294 </svg> 295 ) : ( 296 <svg 297 className="w-5 h-5" 298 fill="none" 299 stroke="currentColor" 300 viewBox="0 0 24 24" 301 > 302 <path 303 strokeLinecap="round" 304 strokeLinejoin="round" 305 strokeWidth={2} 306 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" 307 /> 308 </svg> 309 )} 310 {song.isLikedByCurrentUser ? "Liked" : "Like"} 311 </button> 312 313 {/* Add to Playlist button */} 314 <div className="relative"> 315 <button 316 onClick={() => setShowPlaylistDropdown((prev) => !prev)} 317 className="flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold bg-white/10 text-white hover:bg-white/20 transition-colors cursor-pointer" 318 > 319 <svg 320 className="w-5 h-5" 321 fill="none" 322 stroke="currentColor" 323 viewBox="0 0 24 24" 324 > 325 <path 326 strokeLinecap="round" 327 strokeLinejoin="round" 328 strokeWidth={2} 329 d="M12 4v16m8-8H4" 330 /> 331 </svg> 332 Add to Playlist 333 </button> 334 335 <PlaylistDropdown 336 songId={song.id} 337 isOpen={showPlaylistDropdown} 338 onClose={() => setShowPlaylistDropdown(false)} 339 direction="below" 340 /> 341 </div> 342 </> 343 )} 344 </div> 345 </div> 346 </div> 347 348 {/* Credits / Contributions */} 349 {song.contributions.length > 0 && ( 350 <section className="mb-10"> 351 <h2 className="text-2xl font-bold mb-4">Credits</h2> 352 <div className="bg-[#1a1a2e]/60 rounded-xl p-5 divide-y divide-white/5"> 353 {/* Main artist first */} 354 {song.contributions 355 .filter((c) => c.artistName === song.releasedBy) 356 .map((c, i) => ( 357 <div 358 key={`main-${i}`} 359 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 360 > 361 <div className="flex items-center gap-3"> 362 <div className="w-10 h-10 bg-[#1db954] rounded-full flex items-center justify-center text-black font-bold text-sm"> 363 {c.artistName.charAt(0).toUpperCase()} 364 </div> 365 <div> 366 <p className="text-white font-semibold text-lg"> 367 {c.artistName} 368 </p> 369 </div> 370 </div> 371 <span className="text-sm text-gray-400 bg-white/5 px-3 py-1 rounded-full"> 372 {formatRole(c.role)} 373 </span> 374 </div> 375 ))} 376 377 {/* Other contributors */} 378 {otherContributors.map((c, i) => ( 379 <div 380 key={`contrib-${i}`} 381 className="flex items-center justify-between py-3 first:pt-0 last:pb-0" 382 > 383 <div className="flex items-center gap-3"> 384 <div className="w-9 h-9 bg-white/10 rounded-full flex items-center justify-center text-white font-medium text-sm"> 385 {c.artistName.charAt(0).toUpperCase()} 386 </div> 387 <p className="text-gray-300 font-medium">{c.artistName}</p> 388 </div> 389 <span className="text-sm text-gray-500 bg-white/5 px-3 py-1 rounded-full"> 390 {formatRole(c.role)} 391 </span> 392 </div> 393 ))} 394 </div> 395 </section> 396 )} 397 398 {/* Reviews */} 399 <section> 400 <div className="flex items-center justify-between mb-4"> 401 <h2 className="text-2xl font-bold"> 402 Reviews 403 {song.reviews.length > 0 && ( 404 <span className="text-base font-normal text-gray-500 ml-2"> 405 ({song.reviews.length}) 406 </span> 407 )} 408 </h2> 409 {user != null && ( 410 <button 411 onClick={() => setShowReviewModal(true)} 412 className="flex items-center gap-2 bg-[#1db954] hover:bg-[#1ed760] text-black text-sm font-semibold px-4 py-2 rounded-full transition-colors cursor-pointer" 413 > 414 <svg 415 className="w-4 h-4" 416 fill="none" 417 stroke="currentColor" 418 viewBox="0 0 24 24" 419 > 420 <path 421 strokeLinecap="round" 422 strokeLinejoin="round" 423 strokeWidth={2} 424 d="M12 4v16m8-8H4" 425 /> 426 </svg> 427 Add Review 428 </button> 429 )} 430 </div> 431 432 {song.reviews.length === 0 ? ( 433 <div className="bg-[#1a1a2e]/60 rounded-xl p-10 text-center"> 434 <p className="text-gray-500 text-lg">No reviews yet.</p> 435 <p className="text-gray-600 text-sm mt-1"> 436 Be the first to share your thoughts! 437 </p> 438 </div> 439 ) : ( 440 <div className="space-y-4"> 441 {song.reviews.map((review) => ( 442 <div 443 key={`${review.id.listenerId}-${review.id.musicalEntityId}`} 444 className="bg-[#1a1a2e]/60 rounded-xl p-5 hover:bg-[#1a1a2e]/80 transition-colors" 445 > 446 <div className="flex items-start justify-between mb-2"> 447 <div className="flex items-center gap-3"> 448 <div className="w-9 h-9 bg-blue-500/20 rounded-full flex items-center justify-center"> 449 <span className="text-blue-400 font-semibold text-sm"> 450 {review.author.charAt(0).toUpperCase()} 451 </span> 452 </div> 453 <div> 454 <p className="text-white font-medium"> 455 {review.author} 456 </p> 457 {renderStars(review.grade)} 458 </div> 459 </div> 460 {user?.username === review.authorUsername && ( 461 <button 462 onClick={deleteReview} 463 className="text-gray-500 hover:text-red-400 transition-colors p-2 rounded-lg hover:bg-red-400/10 cursor-pointer" 464 title="Delete review" 465 > 466 <svg 467 className="w-5 h-5" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" 477 /> 478 </svg> 479 </button> 480 )} 481 </div> 482 <p className="text-gray-300 text-sm leading-relaxed mt-3 pl-12"> 483 {review.comment} 484 </p> 485 </div> 486 ))} 487 </div> 488 )} 489 </section> 490 </div> 491 492 {/* Review Modal */} 493 {showReviewModal && ( 494 <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> 495 <div className="bg-[#1a1a2e] rounded-2xl p-6 max-w-md w-full shadow-2xl"> 496 <div className="flex items-center justify-between mb-6"> 497 <h3 className="text-2xl font-bold">Add Review</h3> 498 <button 499 onClick={() => { 500 setShowReviewModal(false); 501 setReviewRating(0); 502 setReviewComment(""); 503 setHoverRating(0); 504 }} 505 className="text-gray-400 hover:text-white transition-colors cursor-pointer" 506 > 507 <svg 508 className="w-6 h-6" 509 fill="none" 510 stroke="currentColor" 511 viewBox="0 0 24 24" 512 > 513 <path 514 strokeLinecap="round" 515 strokeLinejoin="round" 516 strokeWidth={2} 517 d="M6 18L18 6M6 6l12 12" 518 /> 519 </svg> 520 </button> 521 </div> 522 523 {/* Star Rating */} 524 <div className="mb-6"> 525 <label className="block text-sm font-medium mb-3"> 526 Rating <span className="text-red-400">*</span> 527 </label> 528 <div className="flex gap-2"> 529 {[1, 2, 3, 4, 5].map((star) => ( 530 <button 531 key={star} 532 onClick={() => setReviewRating(star)} 533 onMouseEnter={() => setHoverRating(star)} 534 onMouseLeave={() => setHoverRating(0)} 535 className="transition-transform hover:scale-110 cursor-pointer" 536 > 537 <svg 538 className={`w-10 h-10 ${ 539 star <= (hoverRating || reviewRating) 540 ? "text-yellow-400" 541 : "text-gray-600" 542 }`} 543 fill="currentColor" 544 viewBox="0 0 20 20" 545 > 546 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 547 </svg> 548 </button> 549 ))} 550 </div> 551 </div> 552 553 {/* Comment */} 554 <div className="mb-6"> 555 <label className="block text-sm font-medium mb-2"> 556 Comment <span className="text-gray-500">(optional)</span> 557 </label> 558 <textarea 559 value={reviewComment} 560 onChange={(e) => setReviewComment(e.target.value)} 561 placeholder="Share your thoughts about this song..." 562 rows={4} 563 className="w-full bg-[#0f0f1e] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] transition-colors resize-none" 564 /> 565 </div> 566 567 {/* Actions */} 568 <div className="flex gap-3 justify-end"> 569 <button 570 onClick={() => { 571 setShowReviewModal(false); 572 setReviewRating(0); 573 setReviewComment(""); 574 setHoverRating(0); 575 }} 576 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-white/10 hover:bg-white/20 transition-colors cursor-pointer" 577 > 578 Cancel 579 </button> 580 <button 581 onClick={handleSubmitReview} 582 disabled={reviewRating === 0} 583 className="px-5 py-2.5 rounded-full text-sm font-semibold bg-[#1db954] hover:bg-[#1ed760] text-black transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" 584 > 585 Submit 586 </button> 587 </div> 588 </div> 589 </div> 590 )} 591 </div> 592 ); 593 593 }; 594 594
Note:
See TracChangeset
for help on using the changeset viewer.
