Changeset cb4a1da for frontend/src
- Timestamp:
- 02/05/26 20:52:11 (5 months ago)
- Branches:
- main
- Children:
- 2ce7c1e
- Parents:
- 694fc25
- Location:
- frontend/src
- Files:
-
- 4 added
- 6 edited
-
App.tsx (modified) (1 diff)
-
components/Sidebar.tsx (added)
-
components/search/AlbumResult.tsx (added)
-
components/search/SongResult.tsx (added)
-
components/search/UserResult.tsx (added)
-
components/userProfile/UserListModal.tsx (modified) (1 diff)
-
pages/AllUsers.tsx (modified) (1 diff)
-
pages/LandingPage.tsx (modified) (3 diffs)
-
pages/UserDetail.tsx (modified) (2 diffs)
-
utils/types.ts (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
frontend/src/App.tsx
r694fc25 rcb4a1da 1 import { useState } from "react"; 1 2 import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; 2 3 import { ToastContainer } from "react-toastify"; 3 4 import "react-toastify/dist/ReactToastify.css"; 5 import Sidebar from "./components/Sidebar"; 4 6 import AllUsers from "./pages/AllUsers"; 5 7 import LandingPage from "./pages/LandingPage"; 6 8 import Login from "./pages/Login"; 9 import MusicalCollection from "./pages/MusicalCollection"; 7 10 import Nav from "./pages/Nav"; 8 11 import Register from "./pages/Register"; 9 12 import UserDetail from "./pages/UserDetail"; 10 import MusicalCollection from "./pages/MusicalCollection";11 13 12 14 const Layout = () => { 13 return ( 14 <div className="flex flex-col min-h-screen"> 15 <Nav /> 16 <ToastContainer 17 position="top-right" 18 autoClose={2000} 19 hideProgressBar={false} 20 newestOnTop={false} 21 closeOnClick 22 rtl={false} 23 pauseOnFocusLoss 24 draggable 25 pauseOnHover 26 theme="light" 27 /> 28 <main className="grow"> 29 <Outlet /> 30 </main> 31 </div> 32 ); 15 const [isSidebarOpen, setIsSidebarOpen] = useState(true); 16 17 return ( 18 <div className="flex flex-col min-h-screen bg-[#121212]"> 19 <Nav 20 isSidebarOpen={isSidebarOpen} 21 onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} 22 /> 23 <Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} /> 24 <ToastContainer 25 position="top-right" 26 autoClose={2000} 27 hideProgressBar={false} 28 newestOnTop={false} 29 closeOnClick 30 rtl={false} 31 pauseOnFocusLoss 32 draggable 33 pauseOnHover 34 theme="light" 35 /> 36 <main 37 className={`grow transition-all duration-300 pt-20 ${ 38 isSidebarOpen ? "ml-64" : "ml-0" 39 }`} 40 > 41 <Outlet /> 42 </main> 43 </div> 44 ); 33 45 }; 34 46 35 47 const router = createBrowserRouter([ 36 {37 path: "",38 element: <Layout />,39 children: [40 {41 path: "/",42 element: <LandingPage />,43 },44 {45 path: "/login",46 element: <Login />,47 },48 {49 path: "/users",50 element: <AllUsers />,51 },52 {53 path: "/users/:username",54 element: <UserDetail />,55 },56 {57 path: "/register",58 element: <Register />,59 },60 {61 path: "/collection/:type/:id",62 element: <MusicalCollection />,63 },64 ],65 },48 { 49 path: "", 50 element: <Layout />, 51 children: [ 52 { 53 path: "/", 54 element: <LandingPage />, 55 }, 56 { 57 path: "/login", 58 element: <Login />, 59 }, 60 { 61 path: "/users", 62 element: <AllUsers />, 63 }, 64 { 65 path: "/users/:username", 66 element: <UserDetail />, 67 }, 68 { 69 path: "/register", 70 element: <Register />, 71 }, 72 { 73 path: "/collection/:type/:id", 74 element: <MusicalCollection />, 75 }, 76 ], 77 }, 66 78 ]); 67 79 68 80 function App() { 69 return <RouterProvider router={router} />;81 return <RouterProvider router={router} />; 70 82 } 71 83 -
frontend/src/components/userProfile/UserListModal.tsx
r694fc25 rcb4a1da 3 3 4 4 interface ModalProps { 5 title: string;6 users: BaseNonAdminUser[];7 onClose: () => void;8 onFollowToggle: (targetUsername: string) => Promise<void>;5 title: string; 6 users: BaseNonAdminUser[]; 7 onClose: () => void; 8 onFollowToggle: (targetUsername: string) => Promise<void>; 9 9 } 10 10 11 11 const UserListModal = ({ 12 title,13 users,14 onClose,15 onFollowToggle,12 title, 13 users, 14 onClose, 15 onFollowToggle, 16 16 }: ModalProps) => { 17 const navigate = useNavigate();18 const baseURL = import.meta.env.VITE_API_BASE_URL;17 const navigate = useNavigate(); 18 const baseURL = import.meta.env.VITE_API_BASE_URL; 19 19 20 return (21 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">22 <div className="bg-white rounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col">23 <div className="p-4 border-b flex justify-between items-center">24 <h2 className="text-xl font-bold">{title}</h2>25 <button26 onClick={onClose}27 className="text-gray-500 hover:text-black text-2xl cursor-pointer"28 >29 ×30 </button>31 </div>20 return ( 21 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> 22 <div className="bg-white rounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col"> 23 <div className="p-4 border-b flex justify-between items-center"> 24 <h2 className="text-xl font-bold">{title}</h2> 25 <button 26 onClick={onClose} 27 className="text-gray-500 hover:text-black text-2xl cursor-pointer" 28 > 29 × 30 </button> 31 </div> 32 32 33 <div className="overflow-y-auto p-4 flex-1">34 {users.length === 0 ? (35 <p className="text-center text-gray-500 py-8">No users found.</p>36 ) : (37 users.map((u) => (38 <div39 key={u.username}40 className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors"41 >42 <div43 className="flex items-center gap-4 cursor-pointer flex-1"44 onClick={() => {45 onClose();46 navigate(`/users/${u.username}`);47 }}48 >49 <div className="w-10 h-10 rounded-full bg-blue-100 overflow-hidden shrink-0 flex items-center justify-center">50 {u.profilePhoto ? (51 <img52 src={`${baseURL}/${u.profilePhoto}`}53 className="w-full h-full object-cover"54 alt=""55 />56 ) : (57 <span className="text-blue-600 font-bold">58 {u.fullName.charAt(0)}59 </span>60 )}61 </div>62 <p className="font-semibold text-gray-900">{u.fullName}</p>63 </div>33 <div className="overflow-y-auto p-4 flex-1"> 34 {users.length === 0 ? ( 35 <p className="text-center text-gray-500 py-8">No users found.</p> 36 ) : ( 37 users.map((u) => ( 38 <div 39 key={u.username} 40 className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors" 41 > 42 <div 43 className="flex items-center gap-4 cursor-pointer flex-1" 44 onClick={() => { 45 onClose(); 46 navigate(`/users/${u.username}`); 47 }} 48 > 49 <div className="w-10 h-10 rounded-full bg-blue-100 overflow-hidden shrink-0 flex items-center justify-center"> 50 {u.profilePhoto ? ( 51 <img 52 src={`${baseURL}/${u.profilePhoto}`} 53 className="w-full h-full object-cover" 54 alt="" 55 /> 56 ) : ( 57 <span className="text-blue-600 font-bold"> 58 {u.fullName.charAt(0)} 59 </span> 60 )} 61 </div> 62 <p className="font-semibold text-gray-900">{u.fullName}</p> 63 </div> 64 64 65 <button66 onClick={() => onFollowToggle(u.username)}67 className={`px-4 py-1 text-sm font-medium rounded-md transition-colors cursor-pointer ${68 u.isFollowedByCurrentUser69 ? "bg-gray-200 text-gray-700 hover:bg-gray-300"70 : "bg-blue-500 text-white hover:bg-blue-600"71 }`}72 >73 {u.isFollowedByCurrentUser ? "Unfollow" : "Follow"}74 </button>75 </div>76 ))77 )}78 </div>79 </div>80 <div className="absolute inset-0 -z-10" onClick={onClose}></div>81 </div>82 );65 <button 66 onClick={() => onFollowToggle(u.username)} 67 className={`px-4 py-1 text-sm font-medium rounded-md transition-colors cursor-pointer ${ 68 u.isFollowedByCurrentUser 69 ? "bg-gray-200 text-gray-700 hover:bg-gray-300" 70 : "bg-blue-500 text-white hover:bg-blue-600" 71 }`} 72 > 73 {u.isFollowedByCurrentUser ? "Unfollow" : "Follow"} 74 </button> 75 </div> 76 )) 77 )} 78 </div> 79 </div> 80 <div className="absolute inset-0 -z-10" onClick={onClose}></div> 81 </div> 82 ); 83 83 }; 84 84 -
frontend/src/pages/AllUsers.tsx
r694fc25 rcb4a1da 4 4 5 5 interface User { 6 username: string;7 fullName: string;6 username: string; 7 fullName: string; 8 8 } 9 9 10 10 const AllUsers = () => { 11 const [users, setUsers] = useState<User[]>([]);12 const [searchedUser, setSearchedUser] = useState<string>("");13 const [error, setError] = useState<string | null>(null);14 const navigate = useNavigate();11 const [users, setUsers] = useState<User[]>([]); 12 const [searchedUser, setSearchedUser] = useState<string>(""); 13 const [error, setError] = useState<string | null>(null); 14 const navigate = useNavigate(); 15 15 16 useEffect(() => {17 const fetchUsers = async () => {18 const response = axiosInstance.get("/users/all");19 setUsers((await response).data);20 };21 fetchUsers();22 }, []);16 useEffect(() => { 17 const fetchUsers = async () => { 18 const response = axiosInstance.get("/users/na/all"); 19 setUsers((await response).data); 20 }; 21 fetchUsers(); 22 }, []); 23 23 24 const handleUserClick = (username: string) => {25 navigate(`/users/${username}`);26 };24 const handleUserClick = (username: string) => { 25 navigate(`/users/${username}`); 26 }; 27 27 28 const handleSearch = async (e: React.FormEvent) => {29 e.preventDefault();30 try {31 const response = await axiosInstance.get(`/users/search`, {32 params: { name: searchedUser },33 });34 setUsers(response.data);35 } catch (err: any) {36 const errorMessage = err.response?.data?.error || "Search failed";37 setError(errorMessage);38 }39 };28 const handleSearch = async (e: React.FormEvent) => { 29 e.preventDefault(); 30 try { 31 const response = await axiosInstance.get(`/users/na/search`, { 32 params: { name: searchedUser }, 33 }); 34 setUsers(response.data); 35 } catch (err: any) { 36 const errorMessage = err.response?.data?.error || "Search failed"; 37 setError(errorMessage); 38 } 39 }; 40 40 41 if (users.length == 0) return <div className="p-6">Loading...</div>;42 if (error) {43 return (44 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg">45 <h2 className="font-bold">Error</h2>46 <p>{error}</p>47 </div>48 );49 }41 if (users.length == 0) return <div className="p-6">Loading...</div>; 42 if (error) { 43 return ( 44 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 45 <h2 className="font-bold">Error</h2> 46 <p>{error}</p> 47 </div> 48 ); 49 } 50 50 51 return (52 <div className="container mx-auto p-6">53 <h1 className="text-3xl font-bold mb-6">All Users</h1>54 <form onSubmit={handleSearch} className="flex gap-2 mb-10">55 <input56 type="text"57 value={searchedUser}58 onChange={(e) => setSearchedUser(e.target.value)}59 className="border p-2 rounded w-full"60 placeholder="Search for a user..."61 />62 <button63 type="submit"64 className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition-colors"65 >66 Search67 </button>68 </form>51 return ( 52 <div className="container mx-auto p-6"> 53 <h1 className="text-3xl font-bold mb-6">All Users</h1> 54 <form onSubmit={handleSearch} className="flex gap-2 mb-10"> 55 <input 56 type="text" 57 value={searchedUser} 58 onChange={(e) => setSearchedUser(e.target.value)} 59 className="border p-2 rounded w-full" 60 placeholder="Search for a user..." 61 /> 62 <button 63 type="submit" 64 className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition-colors" 65 > 66 Search 67 </button> 68 </form> 69 69 70 <div className="grid gap-4">71 {users.map((u) => (72 <div73 key={u.username}74 onClick={() => handleUserClick(u.username)}75 className="bg-white shadow-md rounded-lg p-4 hover:shadow-lg hover:bg-gray-50 cursor-pointer transition-all"76 >77 <h2 className="text-xl font-semibold">{u.fullName}</h2>78 <p className="text-gray-600">@{u.username}</p>79 </div>80 ))}81 </div>82 </div>83 );70 <div className="grid gap-4"> 71 {users.map((u) => ( 72 <div 73 key={u.username} 74 onClick={() => handleUserClick(u.username)} 75 className="bg-white shadow-md rounded-lg p-4 hover:shadow-lg hover:bg-gray-50 cursor-pointer transition-all" 76 > 77 <h2 className="text-xl font-semibold">{u.fullName}</h2> 78 <p className="text-gray-600">@{u.username}</p> 79 </div> 80 ))} 81 </div> 82 </div> 83 ); 84 84 }; 85 85 -
frontend/src/pages/LandingPage.tsx
r694fc25 rcb4a1da 1 1 import { useEffect, useState } from "react"; 2 2 import axiosInstance from "../api/axiosInstance"; 3 import type { Song } from "../utils/types"; 4 import Nav from "./Nav"; 3 import AlbumResult from "../components/search/AlbumResult"; 4 import SongResult from "../components/search/SongResult"; 5 import UserResult from "../components/search/UserResult"; 6 import type { 7 Album, 8 BaseNonAdminUser, 9 SearchCategory, 10 Song, 11 } from "../utils/types"; 12 13 const CATEGORIES: { value: SearchCategory; label: string }[] = [ 14 { value: "songs", label: "Songs" }, 15 { value: "albums", label: "Albums" }, 16 { value: "artists", label: "Artists" }, 17 { value: "users", label: "Users" }, 18 ]; 5 19 6 20 const LandingPage = () => { 7 21 const [songs, setSongs] = useState<Song[]>([]); 8 22 const [loading, setLoading] = useState<boolean>(true); 9 const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true); 10 11 // mock data for recently listened songs 12 const recentlyListened = [ 13 { id: 1, title: "Song One", artist: "Artist A", cover: "/favicon.png" }, 14 { id: 2, title: "Song Two", artist: "Artist B", cover: "/favicon.png" }, 15 { id: 3, title: "Song Three", artist: "Artist C", cover: "/favicon.png" }, 16 { id: 4, title: "Song Four", artist: "Artist D", cover: "/favicon.png" }, 17 { id: 5, title: "Song Five", artist: "Artist E", cover: "/favicon.png" }, 18 ]; 19 20 // mock data for my playlists 21 const playlists = [ 22 { id: 1, name: "My Favorites", songCount: 25 }, 23 { id: 2, name: "Workout Mix", songCount: 18 }, 24 { id: 3, name: "Chill Vibes", songCount: 32 }, 25 { id: 4, name: "Party Hits", songCount: 45 }, 26 ]; 23 24 // search state 25 const [searchInput, setSearchInput] = useState(""); 26 const [activeQuery, setActiveQuery] = useState(""); 27 const [searchCategory, setSearchCategory] = useState<SearchCategory>("songs"); 28 const [searchResults, setSearchResults] = useState<unknown[]>([]); 29 const [searchLoading, setSearchLoading] = useState(false); 30 const [hasSearched, setHasSearched] = useState(false); 27 31 28 32 useEffect(() => { 29 33 const fetchSongs = async () => { 30 34 try { 31 const response = await axiosInstance.get("/songs"); 32 const data = response.data; 33 console.log("Fetched songs:", data); 34 setSongs(data); 35 const response = await axiosInstance.get("/songs/top"); 36 setSongs(response.data); 35 37 } catch (error) { 36 38 console.error("Error fetching songs:", error); … … 42 44 }, []); 43 45 46 const performSearch = async (query: string, category: SearchCategory) => { 47 if (!query.trim()) return; 48 49 setSearchLoading(true); 50 setHasSearched(true); 51 setActiveQuery(query); 52 setSearchCategory(category); 53 54 try { 55 let endpoint = ""; 56 switch (category) { 57 case "songs": 58 endpoint = `/songs/search?q=${encodeURIComponent(query)}`; 59 break; 60 case "albums": 61 endpoint = `/albums/search?q=${encodeURIComponent(query)}`; 62 break; 63 case "artists": 64 endpoint = `/users/search?type=ARTIST&q=${encodeURIComponent(query)}`; 65 break; 66 case "users": 67 endpoint = `/users/search?type=LISTENER&q=${encodeURIComponent(query)}`; 68 break; 69 } 70 71 const response = await axiosInstance.get(endpoint); 72 setSearchResults(response.data); 73 } catch (error) { 74 console.error("Search error:", error); 75 setSearchResults([]); 76 } finally { 77 setSearchLoading(false); 78 } 79 }; 80 81 const handleSearch = () => { 82 performSearch(searchInput, searchCategory); 83 }; 84 85 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 86 if (e.key === "Enter") { 87 handleSearch(); 88 } 89 }; 90 91 const handleCategorySwitch = (category: SearchCategory) => { 92 performSearch(activeQuery, category); 93 }; 94 95 const clearSearch = () => { 96 setSearchInput(""); 97 setActiveQuery(""); 98 setHasSearched(false); 99 setSearchResults([]); 100 }; 101 102 const renderResults = () => { 103 if (searchLoading) { 104 return ( 105 <div className="flex justify-center py-12"> 106 <div className="w-10 h-10 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 107 </div> 108 ); 109 } 110 111 if (searchResults.length === 0) { 112 return ( 113 <div className="text-center py-12 text-gray-400"> 114 <p className="text-lg"> 115 No {searchCategory} found for "{activeQuery}" 116 </p> 117 </div> 118 ); 119 } 120 121 return ( 122 <div className="divide-y divide-white/5"> 123 {searchResults.map((result, index) => { 124 switch (searchCategory) { 125 case "songs": 126 return ( 127 <SongResult key={(result as Song).id} song={result as Song} /> 128 ); 129 case "albums": 130 return ( 131 <AlbumResult 132 key={(result as Album).id} 133 album={result as Album} 134 /> 135 ); 136 case "artists": 137 return ( 138 <UserResult 139 key={(result as BaseNonAdminUser).username ?? index} 140 user={result as BaseNonAdminUser} 141 label="Artist" 142 /> 143 ); 144 case "users": 145 return ( 146 <UserResult 147 key={(result as BaseNonAdminUser).username ?? index} 148 user={result as BaseNonAdminUser} 149 label="User" 150 /> 151 ); 152 } 153 })} 154 </div> 155 ); 156 }; 157 44 158 return ( 45 <> 46 <Nav 47 isSidebarOpen={isSidebarOpen} 48 onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} 49 /> 50 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white flex pt-20"> 51 {/* Sidebar */} 52 <div 53 className={`fixed left-0 top-0 h-full bg-[#121212] border-r border-white/10 transition-transform duration-300 ease-in-out z-100 ${ 54 isSidebarOpen ? "translate-x-0" : "-translate-x-full" 55 } w-64 overflow-y-auto`} 56 > 57 <div className="p-6"> 58 {/* Sidebar Header */} 59 <div className="flex justify-between items-center mb-6"> 60 <h2 className="text-xl font-bold text-white">Library</h2> 61 <button 62 onClick={() => setIsSidebarOpen(false)} 63 className="text-gray-400 hover:text-white transition-colors" 64 aria-label="Close sidebar" 65 > 66 <svg 67 className="w-6 h-6" 68 fill="none" 69 stroke="currentColor" 70 viewBox="0 0 24 24" 159 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white flex"> 160 <div className="flex-1"> 161 <div className="p-8"> 162 <div className="max-w-7xl mx-auto"> 163 {/* Search Bar */} 164 <div className="mb-8 flex flex-col md:flex-row gap-3"> 165 <div className="flex flex-1 gap-0"> 166 {/* Category Dropdown */} 167 <select 168 value={searchCategory} 169 onChange={(e) => 170 setSearchCategory(e.target.value as SearchCategory) 171 } 172 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" 71 173 > 72 <path 73 strokeLinecap="round" 74 strokeLinejoin="round" 75 strokeWidth={2} 76 d="M6 18L18 6M6 6l12 12" 77 /> 78 </svg> 79 </button> 80 </div> 81 82 {/* Recently Listened */} 83 <div className="mb-8"> 84 <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> 85 Recently Played 86 </h3> 87 <div className="space-y-3"> 88 {recentlyListened.map((song) => ( 89 <div 90 key={song.id} 91 className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 cursor-pointer transition-colors" 174 {CATEGORIES.map((cat) => ( 175 <option key={cat.value} value={cat.value}> 176 {cat.label} 177 </option> 178 ))} 179 </select> 180 181 {/* Search Input */} 182 <input 183 type="text" 184 placeholder={`Search ${searchCategory}...`} 185 value={searchInput} 186 onChange={(e) => setSearchInput(e.target.value)} 187 onKeyDown={handleKeyDown} 188 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" 189 /> 190 191 {/* Search Button */} 192 <button 193 onClick={handleSearch} 194 className="bg-[#1db954] hover:bg-[#1ed760] text-black font-medium px-6 rounded-r-full transition-colors flex items-center gap-2" 195 > 196 <svg 197 className="w-5 h-5" 198 fill="none" 199 stroke="currentColor" 200 viewBox="0 0 24 24" 92 201 > 93 <img 94 src={song.cover} 95 alt={song.title} 96 className="w-10 h-10 rounded object-cover" 202 <path 203 strokeLinecap="round" 204 strokeLinejoin="round" 205 strokeWidth={2} 206 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 97 207 /> 98 <div className="flex-1 min-w-0"> 99 <p className="text-sm font-medium text-white truncate"> 100 {song.title} 101 </p> 102 <p className="text-xs text-gray-400 truncate"> 103 {song.artist} 104 </p> 105 </div> 106 </div> 107 ))} 208 </svg> 209 <span className="hidden sm:inline">Search</span> 210 </button> 108 211 </div> 109 212 </div> 110 213 111 {/* Playlists*/}112 <div>113 < h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">114 Your Playlists115 </h3>116 <div className="space-y-2">117 {playlists.map((playlist) => (118 < div119 key={playlist.id}120 className=" flex items-center justify-between p-3 rounded-lg hover:bg-white/5 cursor-pointertransition-colors"214 {/* Search Results Section */} 215 {hasSearched ? ( 216 <div> 217 <div className="flex items-center justify-between mb-4"> 218 <h2 className="text-2xl font-bold text-white"> 219 Results for "{activeQuery}" 220 </h2> 221 <button 222 onClick={clearSearch} 223 className="text-sm text-gray-400 hover:text-white transition-colors" 121 224 > 122 <div className="flex items-center gap-3"> 123 <div className="w-10 h-10 bg-linear-to-br from-purple-500 to-pink-500 rounded flex items-center justify-center"> 124 <svg 125 className="w-5 h-5 text-white" 126 fill="currentColor" 127 viewBox="0 0 20 20" 128 > 129 <path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" /> 130 </svg> 131 </div> 132 <div> 133 <p className="text-sm font-medium text-white"> 134 {playlist.name} 135 </p> 136 <p className="text-xs text-gray-400"> 137 {playlist.songCount} songs 138 </p> 139 </div> 140 </div> 141 </div> 142 ))} 143 </div> 144 </div> 145 </div> 146 </div> 147 148 {/* Main Content */} 149 <div 150 className={`flex-1 transition-all duration-300 ${ 151 isSidebarOpen ? "ml-64" : "ml-0" 152 }`} 153 > 154 <div className="p-8"> 155 {loading ? ( 156 <div className="flex flex-col items-center justify-center min-h-[60vh] gap-6"> 157 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 158 <p className="text-xl text-gray-400">Loading songs...</p> 225 Clear search 226 </button> 227 </div> 228 229 {/* Quick category switch buttons */} 230 <div className="flex gap-2 mb-6"> 231 {CATEGORIES.map((cat) => ( 232 <button 233 key={cat.value} 234 onClick={() => handleCategorySwitch(cat.value)} 235 className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${ 236 searchCategory === cat.value 237 ? "bg-[#1db954] text-black" 238 : "bg-white/10 text-white hover:bg-white/20" 239 }`} 240 > 241 {cat.label} 242 </button> 243 ))} 244 </div> 245 246 {/* Results list */} 247 <div className="bg-[#1a1a2e]/50 rounded-xl overflow-hidden"> 248 {renderResults()} 249 </div> 159 250 </div> 160 251 ) : ( 161 <div className="max-w-7xl mx-auto"> 162 <div className="mb-12 pb-6 border-b border-white/10"> 252 /* Default song grid */ 253 <> 254 <div className="mb-8 border-b border-white/10 pb-6"> 163 255 <h1 className="text-5xl font-bold mb-3 pb-1 bg-linear-to-r from-[#1db954] to-[#1ed760] bg-clip-text text-transparent"> 164 256 Top Songs … … 168 260 </p> 169 261 </div> 170 <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-8 py-4"> 171 {songs.map((song) => ( 172 <div 173 key={song.id} 174 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" 175 > 176 <div className="relative w-full pt-[100%] overflow-hidden bg-[#181818]"> 177 <img 178 src={song.cover || "/favicon.png"} 179 alt={song.title} 180 className="absolute top-0 left-0 w-full h-full object-cover" 181 onError={(e) => { 182 (e.target as HTMLImageElement).src = "/favicon.png"; 183 }} 184 /> 185 <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"> 186 <div className="w-15 h-15 rounded-full bg-[#1db954] flex items-center justify-center text-2xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 187 ▶ 262 263 {loading ? ( 264 <div className="flex flex-col items-center justify-center min-h-[40vh] gap-6"> 265 <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div> 266 <p className="text-xl text-gray-400">Loading songs...</p> 267 </div> 268 ) : ( 269 <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-8 py-4"> 270 {songs.map((song) => ( 271 <div 272 key={song.id} 273 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" 274 > 275 <div className="relative w-full pt-[100%] overflow-hidden bg-[#181818]"> 276 <img 277 src={song.cover || "/favicon.png"} 278 alt={song.title} 279 className="absolute top-0 left-0 w-full h-full object-cover" 280 onError={(e) => { 281 (e.target as HTMLImageElement).src = 282 "/favicon.png"; 283 }} 284 /> 285 <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"> 286 <div className="w-15 h-15 rounded-full bg-[#1db954] flex items-center justify-center text-2xl text-black shadow-[0_4px_12px_rgba(29,185,84,0.5)]"> 287 ▶ 288 </div> 188 289 </div> 189 290 </div> 291 <div className="p-4 flex flex-col items-center"> 292 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap"> 293 {song.title} 294 </h3> 295 <p className="text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap"> 296 {"<album>"} 297 </p> 298 <p className="text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap"> 299 {song.releasedBy} 300 </p> 301 </div> 190 302 </div> 191 <div className="p-4 flex flex-col items-center"> 192 <h3 className="text-lg font-semibold mb-2 text-white overflow-hidden text-ellipsis whitespace-nowrap"> 193 {song.title} 194 </h3> 195 <p className="text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap"> 196 {"<album>"} 197 </p> 198 <p className="text-sm text-gray-400 mb-3 overflow-hidden text-ellipsis whitespace-nowrap"> 199 {song.releasedBy} 200 </p> 201 {/* <div className="inline-block bg-[#1db954]/20 px-3 py-1 rounded-xl border border-[#1db954]/40"> 202 <span className="text-xs text-[#1db954] font-medium uppercase tracking-wider"> 203 {song.genre} 204 </span> 205 </div> */} 206 </div> 207 </div> 208 ))} 209 </div> 210 </div> 303 ))} 304 </div> 305 )} 306 </> 211 307 )} 212 308 </div> 213 309 </div> 214 310 </div> 215 </ >311 </div> 216 312 ); 217 313 }; -
frontend/src/pages/UserDetail.tsx
r694fc25 rcb4a1da 7 7 import { handleError } from "../utils/error"; 8 8 import type { 9 MusicalEntity,10 Playlist,11 ArtistContribution,12 BaseNonAdminUser,9 ArtistContribution, 10 BaseNonAdminUser, 11 MusicalEntity, 12 Playlist, 13 13 } from "../utils/types"; 14 14 15 15 interface FollowStatus { 16 isFollowing: boolean;17 followerCount: number;18 followingCount: number;16 isFollowing: boolean; 17 followerCount: number; 18 followingCount: number; 19 19 } 20 20 21 21 interface Artist extends BaseNonAdminUser { 22 userType: "ARTIST";23 contributions: ArtistContribution[];22 userType: "ARTIST"; 23 contributions: ArtistContribution[]; 24 24 } 25 25 interface Listener extends BaseNonAdminUser { 26 userType: "LISTENER";27 likedEntities: MusicalEntity[];28 createdPlaylists: Playlist[];29 savedPlaylists: Playlist[];26 userType: "LISTENER"; 27 likedEntities: MusicalEntity[]; 28 createdPlaylists: Playlist[]; 29 savedPlaylists: Playlist[]; 30 30 } 31 31 … … 33 33 34 34 const UserDetail = () => { 35 // user refers to the selected user NOT to the user from context 36 const baseURL = import.meta.env.VITE_API_BASE_URL; 37 const { username } = useParams(); 38 const navigate = useNavigate(); 39 const [user, setUser] = useState<UserProfile | null>(null); 40 const [error, setError] = useState<string | null>(null); 41 const [showModal, setShowModal] = useState(false); 42 const [modalTitle, setModalTitle] = useState(""); 43 const [modalUsers, setModalUsers] = useState<any[]>([]); 44 const [isLoadingModal, setIsLoadingModal] = useState(false); 45 const [isFollowing, setIsFollowing] = useState(false); 46 47 const handleFollow = async () => { 48 if (!user) return; 49 50 setIsFollowing(true); 51 try { 52 const response = await axiosInstance.post<FollowStatus>( 53 `/users/${username}/follow`, 54 ); 55 setUser((prev) => { 56 if (!prev) return null; 57 return { 58 ...prev, 59 isFollowedByCurrentUser: response.data.isFollowing, 60 followers: response.data.followerCount, 61 following: response.data.followingCount, 62 }; 63 }); 64 } catch (err: any) { 65 setError(handleError(err)); 66 } finally { 67 setIsFollowing(false); 68 } 69 }; 70 71 const handleFollowInModal = async (targetUsername: string) => { 72 try { 73 const response = await axiosInstance.post<FollowStatus>( 74 `/users/${username}/follow`, 75 ); 76 77 setModalUsers((prevUsers) => 78 prevUsers.map((u) => 79 u.username === targetUsername 80 ? { ...u, isFollowedByCurrentUser: response.data.isFollowing } 81 : u, 82 ), 83 ); 84 85 // if (user && user.id === targetId) { 86 // setUser((prev) => { 87 // if (!prev) return null; 88 // return { 89 // ...prev, 90 // isFollowedByCurrentUser: response.data.isFollowing, 91 // followers: response.data.followerCount, 92 // }; 93 // }); 94 // } 95 } catch (err: any) { 96 setError(handleError(err)); 97 } 98 }; 99 100 const displayFollowers = async () => { 101 setIsLoadingModal(true); 102 try { 103 const response = await axiosInstance.get(`/users/${username}/followers`); 104 setModalUsers(response.data); 105 setModalTitle("Followers"); 106 setShowModal(true); 107 } catch (err) { 108 setError(handleError(err)); 109 } finally { 110 setIsLoadingModal(false); 111 } 112 }; 113 const displayFollowing = async () => { 114 setIsLoadingModal(true); 115 try { 116 const response = await axiosInstance.get(`/users/${username}/following`); 117 setModalUsers(response.data); 118 setModalTitle("Following"); 119 setShowModal(true); 120 } catch (err: any) { 121 setError(handleError(err)); 122 } finally { 123 setIsLoadingModal(false); 124 } 125 }; 126 127 useEffect(() => { 128 const fetchUser = async () => { 129 setError(null); 130 try { 131 const response = await axiosInstance.get(`/users/${username}`); 132 console.log(response.data); 133 setUser(response.data); 134 } catch (err: any) { 135 setError(handleError(err)); 136 } 137 }; 138 fetchUser(); 139 }, [username]); 140 141 if (error) { 142 return ( 143 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 144 <h2 className="font-bold">Error</h2> 145 <p>{error}</p> 146 </div> 147 ); 148 } 149 150 if (!user) return <div className="p-6">Loading...</div>; 151 152 return ( 153 <div className="container mx-auto p-6"> 154 {isLoadingModal && ( 155 <div className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm flex items-center justify-center"> 156 <div className="flex items-center gap-3"> 157 <div className="w-6 h-6 border-3 border-blue-500 border-t-transparent rounded-full animate-spin"></div> 158 </div> 159 </div> 160 )} 161 <button 162 onClick={() => navigate(-1)} 163 className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 164 > 165 ← Back 166 </button> 167 168 <div className="bg-white shadow-lg rounded-lg p-8"> 169 <div className="flex items-start gap-6 mb-8"> 170 <div className="shrink-0"> 171 <div className="w-32 h-32 rounded-full bg-linear-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold shadow-lg overflow-hidden"> 172 {user.profilePhoto ? ( 173 <img 174 src={`${baseURL}/${user.profilePhoto}`} 175 alt={user.fullName} 176 className="w-full h-full object-cover" 177 /> 178 ) : ( 179 user.fullName.charAt(0).toUpperCase() 180 )} 181 </div> 182 </div> 183 184 <div className="flex-1"> 185 <h1 className="text-4xl font-bold mb-2">{user.fullName}</h1> 186 <span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium mb-4"> 187 {user.userType} 188 </span> 189 190 <div className="flex gap-6 mb-4 text-gray-700"> 191 <div 192 className={`flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`} 193 onClick={ 194 user.userType === "LISTENER" ? displayFollowers : undefined 195 } 196 > 197 <span className="text-2xl font-bold">{user.followers}</span> 198 <span className="text-sm text-gray-500">Followers</span> 199 </div> 200 <div 201 className={`flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`} 202 onClick={ 203 user.userType === "LISTENER" ? displayFollowing : undefined 204 } 205 > 206 <span className="text-2xl font-bold">{user.following}</span> 207 <span className="text-sm text-gray-500">Following</span> 208 </div> 209 </div> 210 211 <button 212 onClick={handleFollow} 213 disabled={isFollowing} 214 className={` 35 // user refers to the selected user NOT to the user from context 36 const baseURL = import.meta.env.VITE_API_BASE_URL; 37 const { username } = useParams(); 38 const navigate = useNavigate(); 39 const [user, setUser] = useState<UserProfile | null>(null); 40 const [error, setError] = useState<string | null>(null); 41 const [showModal, setShowModal] = useState(false); 42 const [modalTitle, setModalTitle] = useState(""); 43 const [modalUsers, setModalUsers] = useState<any[]>([]); 44 const [isLoadingModal, setIsLoadingModal] = useState(false); 45 const [isFollowing, setIsFollowing] = useState(false); 46 47 const handleFollow = async () => { 48 if (!user) return; 49 50 setIsFollowing(true); 51 try { 52 const response = await axiosInstance.post<FollowStatus>( 53 `/users/na/${username}/follow`, 54 ); 55 setUser((prev) => { 56 if (!prev) return null; 57 return { 58 ...prev, 59 isFollowedByCurrentUser: response.data.isFollowing, 60 followers: response.data.followerCount, 61 following: response.data.followingCount, 62 }; 63 }); 64 } catch (err: any) { 65 setError(handleError(err)); 66 } finally { 67 setIsFollowing(false); 68 } 69 }; 70 71 const handleFollowInModal = async (targetUsername: string) => { 72 try { 73 const response = await axiosInstance.post<FollowStatus>( 74 `/users/na/${username}/follow`, 75 ); 76 77 setModalUsers((prevUsers) => 78 prevUsers.map((u) => 79 u.username === targetUsername 80 ? { ...u, isFollowedByCurrentUser: response.data.isFollowing } 81 : u, 82 ), 83 ); 84 85 // if (user && user.id === targetId) { 86 // setUser((prev) => { 87 // if (!prev) return null; 88 // return { 89 // ...prev, 90 // isFollowedByCurrentUser: response.data.isFollowing, 91 // followers: response.data.followerCount, 92 // }; 93 // }); 94 // } 95 } catch (err: any) { 96 setError(handleError(err)); 97 } 98 }; 99 100 const displayFollowers = async () => { 101 setIsLoadingModal(true); 102 try { 103 const response = await axiosInstance.get( 104 `/users/na/${username}/followers`, 105 ); 106 setModalUsers(response.data); 107 setModalTitle("Followers"); 108 setShowModal(true); 109 } catch (err) { 110 setError(handleError(err)); 111 } finally { 112 setIsLoadingModal(false); 113 } 114 }; 115 const displayFollowing = async () => { 116 setIsLoadingModal(true); 117 try { 118 const response = await axiosInstance.get( 119 `/users/na/${username}/following`, 120 ); 121 setModalUsers(response.data); 122 setModalTitle("Following"); 123 setShowModal(true); 124 } catch (err: any) { 125 setError(handleError(err)); 126 } finally { 127 setIsLoadingModal(false); 128 } 129 }; 130 131 useEffect(() => { 132 const fetchUser = async () => { 133 setError(null); 134 try { 135 const response = await axiosInstance.get(`/users/na/${username}`); 136 console.log(response.data); 137 setUser(response.data); 138 } catch (err: any) { 139 setError(handleError(err)); 140 } 141 }; 142 fetchUser(); 143 }, [username]); 144 145 if (error) { 146 return ( 147 <div className="p-6 bg-red-50 border border-red-200 text-red-700 rounded-lg"> 148 <h2 className="font-bold">Error</h2> 149 <p>{error}</p> 150 </div> 151 ); 152 } 153 154 if (!user) return <div className="p-6">Loading...</div>; 155 156 return ( 157 <div className="container mx-auto p-6"> 158 {isLoadingModal && ( 159 <div className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm flex items-center justify-center"> 160 <div className="flex items-center gap-3"> 161 <div className="w-6 h-6 border-3 border-blue-500 border-t-transparent rounded-full animate-spin"></div> 162 </div> 163 </div> 164 )} 165 <button 166 onClick={() => navigate(-1)} 167 className="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors duration-200 cursor-pointer" 168 > 169 ← Back 170 </button> 171 172 <div className="bg-white shadow-lg rounded-lg p-8"> 173 <div className="flex items-start gap-6 mb-8"> 174 <div className="shrink-0"> 175 <div className="w-32 h-32 rounded-full bg-linear-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-4xl font-bold shadow-lg overflow-hidden"> 176 {user.profilePhoto ? ( 177 <img 178 src={`${baseURL}/${user.profilePhoto}`} 179 alt={user.fullName} 180 className="w-full h-full object-cover" 181 /> 182 ) : ( 183 user.fullName.charAt(0).toUpperCase() 184 )} 185 </div> 186 </div> 187 188 <div className="flex-1"> 189 <h1 className="text-4xl font-bold mb-2">{user.fullName}</h1> 190 <span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium mb-4"> 191 {user.userType} 192 </span> 193 194 <div className="flex gap-6 mb-4 text-gray-700"> 195 <div 196 className={`flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`} 197 onClick={ 198 user.userType === "LISTENER" ? displayFollowers : undefined 199 } 200 > 201 <span className="text-2xl font-bold">{user.followers}</span> 202 <span className="text-sm text-gray-500">Followers</span> 203 </div> 204 <div 205 className={`flex flex-col ${user.userType == "LISTENER" ? "cursor-pointer" : "cursor-default"}`} 206 onClick={ 207 user.userType === "LISTENER" ? displayFollowing : undefined 208 } 209 > 210 <span className="text-2xl font-bold">{user.following}</span> 211 <span className="text-sm text-gray-500">Following</span> 212 </div> 213 </div> 214 215 <button 216 onClick={handleFollow} 217 disabled={isFollowing} 218 className={` 215 219 px-6 py-2 font-semibold rounded-lg shadow-md 216 220 transition-colors duration-200 217 221 ${ 218 isFollowing219 ? "bg-gray-400 text-gray-200 cursor-not-allowed"220 : user.isFollowedByCurrentUser221 ? "bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-pointer"222 : "bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"223 }222 isFollowing 223 ? "bg-gray-400 text-gray-200 cursor-not-allowed" 224 : user.isFollowedByCurrentUser 225 ? "bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-pointer" 226 : "bg-blue-500 text-white hover:bg-blue-600 cursor-pointer" 227 } 224 228 `} 225 >226 {user.isFollowedByCurrentUser ? "Unfollow" : "Follow"}227 </button>228 </div>229 </div>230 231 {user.userType === "ARTIST" ? (232 <ArtistView contributions={user.contributions} />233 ) : (234 <ListenerView235 likedEntities={user.likedEntities}236 createdPlaylists={user.createdPlaylists}237 savedPlaylists={user.savedPlaylists}238 />239 )}240 241 {showModal && (242 <UserListModal243 title={modalTitle}244 users={modalUsers}245 onClose={() => setShowModal(false)}246 onFollowToggle={handleFollowInModal}247 />248 )}249 </div>250 </div>251 );229 > 230 {user.isFollowedByCurrentUser ? "Unfollow" : "Follow"} 231 </button> 232 </div> 233 </div> 234 235 {user.userType === "ARTIST" ? ( 236 <ArtistView contributions={user.contributions} /> 237 ) : ( 238 <ListenerView 239 likedEntities={user.likedEntities} 240 createdPlaylists={user.createdPlaylists} 241 savedPlaylists={user.savedPlaylists} 242 /> 243 )} 244 245 {showModal && ( 246 <UserListModal 247 title={modalTitle} 248 users={modalUsers} 249 onClose={() => setShowModal(false)} 250 onFollowToggle={handleFollowInModal} 251 /> 252 )} 253 </div> 254 </div> 255 ); 252 256 }; 253 257 -
frontend/src/utils/types.ts
r694fc25 rcb4a1da 55 55 56 56 export type UserRegisterType = "ARTIST" | "LISTENER"; 57 58 export type SearchCategory = "songs" | "albums" | "artists" | "users";
Note:
See TracChangeset
for help on using the changeset viewer.
