source: frontend/src/pages/LandingPage.tsx@ cb4a1da

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

add working server side search to landing page; change some endpoints

  • Property mode set to 100644
File size: 9.6 KB
Line 
1import { useEffect, useState } from "react";
2import axiosInstance from "../api/axiosInstance";
3import AlbumResult from "../components/search/AlbumResult";
4import SongResult from "../components/search/SongResult";
5import UserResult from "../components/search/UserResult";
6import type {
7 Album,
8 BaseNonAdminUser,
9 SearchCategory,
10 Song,
11} from "../utils/types";
12
13const 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];
19
20const LandingPage = () => {
21 const [songs, setSongs] = useState<Song[]>([]);
22 const [loading, setLoading] = useState<boolean>(true);
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);
31
32 useEffect(() => {
33 const fetchSongs = async () => {
34 try {
35 const response = await axiosInstance.get("/songs/top");
36 setSongs(response.data);
37 } catch (error) {
38 console.error("Error fetching songs:", error);
39 } finally {
40 setLoading(false);
41 }
42 };
43 fetchSongs();
44 }, []);
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
158 return (
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"
173 >
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"
201 >
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"
207 />
208 </svg>
209 <span className="hidden sm:inline">Search</span>
210 </button>
211 </div>
212 </div>
213
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"
224 >
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>
250 </div>
251 ) : (
252 /* Default song grid */
253 <>
254 <div className="mb-8 border-b border-white/10 pb-6">
255 <h1 className="text-5xl font-bold mb-3 pb-1 bg-linear-to-r from-[#1db954] to-[#1ed760] bg-clip-text text-transparent">
256 Top Songs
257 </h1>
258 <p className="text-xl text-gray-400">
259 Listen to the newest tracks on FinkWave
260 </p>
261 </div>
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>
289 </div>
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>
302 </div>
303 ))}
304 </div>
305 )}
306 </>
307 )}
308 </div>
309 </div>
310 </div>
311 </div>
312 );
313};
314
315export default LandingPage;
Note: See TracBrowser for help on using the repository browser.