aboutsummaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2025-10-27 22:12:56 +0300
committerArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2025-10-27 23:13:45 +0400
commitdd5ea1b1fcbb21c919a16bc70c6507b097c12f6b (patch)
treeeba2d3b52bc26021ac31d76477e3ac672d1db096 /frontend/src
parentfeat/backend: timeline stats endpoint (diff)
downloadlphub-dd5ea1b1fcbb21c919a16bc70c6507b097c12f6b.tar.gz
lphub-dd5ea1b1fcbb21c919a16bc70c6507b097c12f6b.tar.bz2
lphub-dd5ea1b1fcbb21c919a16bc70c6507b097c12f6b.zip
feat/frontend: homepage with timeline and recent scores
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/api/Api.ts4
-rw-r--r--frontend/src/api/Stats.ts52
-rw-r--r--frontend/src/css/Homepage.css545
-rw-r--r--frontend/src/pages/Homepage.tsx240
4 files changed, 834 insertions, 7 deletions
diff --git a/frontend/src/api/Api.ts b/frontend/src/api/Api.ts
index dd5076a..4385f2c 100644
--- a/frontend/src/api/Api.ts
+++ b/frontend/src/api/Api.ts
@@ -5,6 +5,7 @@ import { get_games, get_chapters, get_games_chapters, get_game_maps, get_search
5import { get_official_rankings, get_unofficial_rankings } from "@api/Rankings"; 5import { get_official_rankings, get_unofficial_rankings } from "@api/Rankings";
6import { get_map_summary, get_map_leaderboard, get_map_discussions, get_map_discussion, post_map_discussion, post_map_discussion_comment, delete_map_discussion, post_record, delete_map_record } from "@api/Maps"; 6import { get_map_summary, get_map_leaderboard, get_map_discussions, get_map_discussion, post_map_discussion, post_map_discussion_comment, delete_map_discussion, post_record, delete_map_record } from "@api/Maps";
7import { delete_map_summary, post_map_summary, put_map_image, put_map_summary } from "@api/Mod"; 7import { delete_map_summary, post_map_summary, put_map_image, put_map_summary } from "@api/Mod";
8import { get_portal_count_history, get_recent_scores } from "@api/Stats";
8import { UploadRunContent } from "@customTypes/Content"; 9import { UploadRunContent } from "@customTypes/Content";
9 10
10// add new api call function entries here 11// add new api call function entries here
@@ -47,6 +48,9 @@ export const API = {
47 put_map_summary: (token: string, map_id: string, content: ModMenuContent) => put_map_summary(token, map_id, content), 48 put_map_summary: (token: string, map_id: string, content: ModMenuContent) => put_map_summary(token, map_id, content),
48 49
49 delete_map_summary: (token: string, map_id: string, route_id: number) => delete_map_summary(token, map_id, route_id), 50 delete_map_summary: (token: string, map_id: string, route_id: number) => delete_map_summary(token, map_id, route_id),
51 // Stats
52 get_portal_count_history: () => get_portal_count_history(),
53 get_recent_scores: () => get_recent_scores(),
50}; 54};
51 55
52const BASE_API_URL: string = import.meta.env.DEV 56const BASE_API_URL: string = import.meta.env.DEV
diff --git a/frontend/src/api/Stats.ts b/frontend/src/api/Stats.ts
new file mode 100644
index 0000000..21654b5
--- /dev/null
+++ b/frontend/src/api/Stats.ts
@@ -0,0 +1,52 @@
1import axios from "axios";
2import { url } from "./Api";
3
4export interface PortalCountData {
5 date: string;
6 count: number;
7}
8
9export interface RecordsTimelineResponse {
10 timeline_singleplayer: PortalCountData[];
11 timeline_multiplayer: PortalCountData[];
12}
13
14export interface ScoreLog {
15 game: {
16 id: number;
17 name: string;
18 image: string;
19 is_coop: boolean;
20 category_portals: null;
21 };
22 user: {
23 steam_id: string;
24 user_name: string;
25 };
26 map: {
27 id: number;
28 name: string;
29 image: string;
30 is_disabled: boolean;
31 portal_count: number;
32 difficulty: number;
33 };
34 score_count: number;
35}
36
37export async function get_portal_count_history(): Promise<RecordsTimelineResponse | undefined> {
38 const response = await axios.get(url("stats/timeline"));
39 if (!response.data.data) {
40 return undefined;
41 }
42 return response.data.data;
43}
44
45export async function get_recent_scores(): Promise<ScoreLog[]> {
46 const response = await axios.get(url("stats/scores"));
47 if (!response.data.data) {
48 return [];
49 }
50 return response.data.data.scores.slice(0, 5);
51}
52
diff --git a/frontend/src/css/Homepage.css b/frontend/src/css/Homepage.css
new file mode 100644
index 0000000..b89602e
--- /dev/null
+++ b/frontend/src/css/Homepage.css
@@ -0,0 +1,545 @@
1.hero-section {
2 text-align: center;
3 padding: 20px 20px;
4 margin: 20px;
5 background: #202232;
6 border-radius: 24px;
7}
8
9.hero-content {
10 max-width: 800px;
11 margin: 0 auto;
12}
13
14.hero-title {
15 font-size: 56px;
16 font-family: BarlowCondensed-Bold;
17 color: #FFF;
18 margin-bottom: 20px;
19 line-height: 1.2;
20}
21
22.hero-subtitle {
23 font-size: 24px;
24 font-family: BarlowSemiCondensed-Regular;
25 color: #CDCFDF;
26 margin: 0;
27}
28
29.stats-section {
30 margin: 20px;
31}
32
33.stats-grid {
34 display: grid;
35 grid-template-columns: 4fr 1fr;
36 gap: 20px;
37}
38
39.stats-container {
40 background: #202232;
41 border-radius: 24px;
42 padding: 20px;
43}
44
45.stats-header {
46 text-align: center;
47 margin-bottom: 30px;
48}
49
50.stats-header h3 {
51 font-size: 32px;
52 font-family: BarlowCondensed-Bold;
53 color: #FFF;
54 margin-top: 0px;
55 margin-bottom: 10px;
56}
57
58.stats-header p {
59 color: #CDCFDF;
60 font-size: 20px;
61 font-family: BarlowSemiCondensed-Regular;
62 margin: 0;
63}
64
65/* Chart Wrapper */
66.chart-wrapper {
67 background: #2b2e46;
68 border-radius: 20px;
69 padding: 20px 10px;
70 margin-top: 20px;
71}
72
73.chart-loading {
74 display: flex;
75 flex-direction: column;
76 align-items: center;
77 justify-content: center;
78 padding: 60px 20px;
79 color: #CDCFDF;
80}
81
82.loading-spinner {
83 width: 48px;
84 height: 48px;
85 border: 5px solid #FFF;
86 border-bottom-color: transparent;
87 border-radius: 50%;
88 display: inline-block;
89 box-sizing: border-box;
90 animation: rotation 1s linear infinite;
91 margin-bottom: 20px;
92}
93
94@keyframes rotation {
95 0% {
96 transform: rotate(0deg);
97 }
98
99 100% {
100 transform: rotate(360deg);
101 }
102}
103
104.chart-loading p {
105 font-size: 20px;
106 font-family: BarlowSemiCondensed-Regular;
107 margin: 0;
108}
109
110.chart-empty {
111 text-align: center;
112 padding: 60px 20px;
113 color: #CDCFDF;
114 font-size: 20px;
115 font-family: BarlowSemiCondensed-Regular;
116}
117
118/* Custom Tooltip */
119.custom-tooltip {
120 background: #2b2e46;
121 border-radius: 12px;
122 padding: 12px 16px;
123}
124
125.tooltip-date {
126 color: #CDCFDF;
127 font-family: BarlowSemiCondensed-Regular;
128 font-size: 16px;
129 margin: 0 0 4px 0;
130}
131
132.tooltip-count {
133 color: #FFF;
134 font-family: BarlowSemiCondensed-SemiBold;
135 font-size: 22px;
136 margin: 0;
137}
138
139/* Mode Toggle Buttons */
140.mode-toggle-container {
141 display: flex;
142 justify-content: center;
143 margin-top: 20px;
144}
145
146.mode-toggle-button {
147 background-color: #2b2e46;
148 padding: 10px 20px;
149 border: 0;
150 color: #cdcfdf;
151 cursor: pointer;
152 font-family: BarlowSemiCondensed-Regular;
153 font-size: 24px;
154 transition: all 0.1s;
155 flex: 1;
156 max-width: 150px;
157}
158
159.mode-toggle-button:first-child {
160 border-radius: 5px 0 0 5px;
161}
162
163.mode-toggle-button:last-child {
164 border-radius: 0 5px 5px 0;
165}
166
167.mode-toggle-button:hover,
168.mode-toggle-button.selected {
169 background-color: #202232;
170}
171
172/* Recent Scores */
173.recent-scores-container {
174 background: #202232;
175 border-radius: 24px;
176 padding: 20px;
177 display: flex;
178 flex-direction: column;
179}
180
181.recent-scores-header {
182 text-align: center;
183 margin-bottom: 20px;
184}
185
186.recent-scores-header h3 {
187 font-size: 32px;
188 font-family: BarlowCondensed-Bold;
189 color: #FFF;
190 margin: 0;
191}
192
193.scores-loading {
194 display: flex;
195 justify-content: center;
196 align-items: center;
197 flex: 1;
198 padding: 40px 20px;
199}
200
201.recent-scores-list {
202 display: flex;
203 flex-direction: column;
204 gap: 10px;
205}
206
207.score-item {
208 background: #2b2e46;
209 border-radius: 16px;
210 padding: 12px 16px;
211 transition: background-color 0.15s;
212}
213
214.score-item:hover {
215 background: #353854;
216}
217
218.score-user {
219 font-family: BarlowSemiCondensed-SemiBold;
220 font-size: 18px;
221 color: #FFF;
222 margin-bottom: 4px;
223}
224
225.score-map {
226 font-family: BarlowSemiCondensed-Regular;
227 font-size: 16px;
228 color: #CDCFDF;
229 margin-bottom: 2px;
230}
231
232.score-portals {
233 font-family: BarlowSemiCondensed-Regular;
234 font-size: 14px;
235 color: #888;
236}
237
238.scores-empty {
239 text-align: center;
240 padding: 40px 20px;
241 color: #CDCFDF;
242 font-size: 16px;
243 font-family: BarlowSemiCondensed-Regular;
244}
245
246/* Info Section */
247.info-section {
248 margin: 20px;
249}
250
251.info-cards {
252 display: grid;
253 grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
254 gap: 20px;
255}
256
257.info-card {
258 background: #202232;
259 border-radius: 24px;
260 padding: 30px;
261 text-align: center;
262 transition: background-color 0.15s;
263}
264
265.info-card:hover {
266 background: #2b2e46;
267}
268
269.info-icon {
270 font-size: 50px;
271 margin-bottom: 20px;
272}
273
274.info-card h3 {
275 font-size: 32px;
276 font-family: BarlowCondensed-Bold;
277 color: #FFF;
278 margin-bottom: 15px;
279}
280
281.info-card p {
282 color: #CDCFDF;
283 font-size: 18px;
284 font-family: BarlowSemiCondensed-Regular;
285 line-height: 1.6;
286 margin: 0;
287}
288
289/* Notice Section */
290.notice-section {
291 background: #202232;
292 border-radius: 24px;
293 padding: 30px;
294 margin: 20px;
295}
296
297.notice-content h3 {
298 color: #FFF;
299 font-size: 40px;
300 font-family: BarlowCondensed-Bold;
301 margin-top: 0;
302 margin-bottom: 20px;
303}
304
305.notice-content p {
306 color: #CDCFDF;
307 font-size: 20px;
308 font-family: BarlowSemiCondensed-Regular;
309 line-height: 1.8;
310 margin-bottom: 15px;
311}
312
313.notice-content p:last-child {
314 margin-bottom: 0;
315}
316
317.notice-content strong {
318 color: #FFF;
319 font-family: BarlowSemiCondensed-SemiBold;
320}
321
322/* Responsive Design */
323@media screen and (min-width: 769px) and (max-width: 1024px) {
324 .hero-section {
325 margin: 18px;
326 padding: 40px 18px;
327 }
328
329 .hero-title {
330 font-size: 48px;
331 }
332
333 .hero-subtitle {
334 font-size: 22px;
335 }
336
337 .stats-section {
338 margin: 18px;
339 }
340
341 .stats-grid {
342 gap: 18px;
343 }
344
345 .stats-container {
346 padding: 18px;
347 }
348
349 .stats-header h3 {
350 font-size: 44px;
351 }
352
353 .stats-header p {
354 font-size: 18px;
355 }
356
357 .chart-wrapper {
358 padding: 18px 10px;
359 }
360
361 .recent-scores-container {
362 padding: 18px;
363 }
364
365 .recent-scores-header h3 {
366 font-size: 28px;
367 }
368
369 .score-user {
370 font-size: 16px;
371 }
372
373 .score-map {
374 font-size: 14px;
375 }
376
377 .score-portals {
378 font-size: 12px;
379 }
380
381 .info-section {
382 margin: 18px;
383 }
384
385 .info-cards {
386 gap: 18px;
387 }
388
389 .info-card {
390 padding: 25px;
391 }
392
393 .info-card h3 {
394 font-size: 28px;
395 }
396
397 .info-card p {
398 font-size: 16px;
399 }
400
401 .notice-section {
402 margin: 18px;
403 padding: 25px;
404 }
405
406 .notice-content h3 {
407 font-size: 36px;
408 }
409
410 .notice-content p {
411 font-size: 18px;
412 }
413}
414
415@media screen and (max-width: 768px) {
416 .hero-section {
417 margin: 20px;
418 padding: 30px 20px;
419 }
420
421 .hero-title {
422 font-size: 40px;
423 }
424
425 .hero-subtitle {
426 font-size: 18px;
427 }
428
429 .stats-section {
430 margin: 20px;
431 }
432
433 .stats-grid {
434 grid-template-columns: 1fr;
435 gap: 15px;
436 }
437
438 .stats-container {
439 padding: 15px;
440 }
441
442 .stats-header h3 {
443 font-size: 36px;
444 margin-bottom: 8px;
445 }
446
447 .stats-header p {
448 font-size: 16px;
449 }
450
451 .chart-wrapper {
452 padding: 15px 5px;
453 margin-top: 15px;
454 }
455
456 .recent-scores-container {
457 padding: 15px;
458 }
459
460 .recent-scores-header h3 {
461 font-size: 28px;
462 }
463
464 .score-item {
465 padding: 10px 12px;
466 }
467
468 .score-user {
469 font-size: 16px;
470 }
471
472 .score-map {
473 font-size: 14px;
474 }
475
476 .score-portals {
477 font-size: 12px;
478 }
479
480 .chart-loading,
481 .chart-empty {
482 padding: 40px 15px;
483 }
484
485 .chart-loading p,
486 .chart-empty p {
487 font-size: 16px;
488 }
489
490 .loading-spinner {
491 width: 40px;
492 height: 40px;
493 border-width: 4px;
494 }
495
496 .info-section {
497 margin: 20px;
498 }
499
500 .info-cards {
501 grid-template-columns: 1fr;
502 gap: 15px;
503 }
504
505 .info-card {
506 padding: 20px;
507 }
508
509 .info-icon {
510 font-size: 40px;
511 margin-bottom: 15px;
512 }
513
514 .info-card h3 {
515 font-size: 28px;
516 margin-bottom: 12px;
517 }
518
519 .info-card p {
520 font-size: 16px;
521 }
522
523 .notice-section {
524 margin: 20px;
525 padding: 20px;
526 }
527
528 .notice-content h3 {
529 font-size: 32px;
530 margin-bottom: 15px;
531 }
532
533 .notice-content p {
534 font-size: 16px;
535 margin-bottom: 12px;
536 }
537
538 .tooltip-date {
539 font-size: 14px;
540 }
541
542 .tooltip-count {
543 font-size: 18px;
544 }
545} \ No newline at end of file
diff --git a/frontend/src/pages/Homepage.tsx b/frontend/src/pages/Homepage.tsx
index 3f30d9a..88290dd 100644
--- a/frontend/src/pages/Homepage.tsx
+++ b/frontend/src/pages/Homepage.tsx
@@ -1,20 +1,246 @@
1import React from "react"; 1import React from "react";
2import { Helmet } from "react-helmet"; 2import { Helmet } from "react-helmet";
3import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from "recharts";
4import { API } from "../api/Api";
5import { PortalCountData, ScoreLog } from "../api/Stats";
6import "../css/Homepage.css";
7import { Link } from "react-router-dom";
3 8
4const Homepage: React.FC = () => { 9const Homepage: React.FC = () => {
10 const [portalCountDataSingleplayer, setPortalCountDataSingleplayer] = React.useState<PortalCountData[]>([]);
11 const [portalCountDataMultiplayer, setPortalCountDataMultiplayer] = React.useState<PortalCountData[]>([]);
12 const [recentScores, setRecentScores] = React.useState<ScoreLog[]>([]);
13 const [isLoading, setIsLoading] = React.useState<boolean>(true);
14 const [isLoadingScores, setIsLoadingScores] = React.useState<boolean>(true);
15 const [selectedMode, setSelectedMode] = React.useState<"singleplayer" | "multiplayer">("singleplayer");
16
17 const processTimelineData = (data: PortalCountData[]): PortalCountData[] => {
18 if (data.length === 0) {
19 return [];
20 };
21 const sortedData = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
22 const startDate = new Date(sortedData[0].date);
23 const endDate = new Date(sortedData[sortedData.length - 1].date);
24
25 const result: PortalCountData[] = [];
26 let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
27
28 let dataIndex = 0;
29 let currentCount = sortedData[0].count;
30
31 while (currentDate <= endDate) {
32 while (dataIndex < sortedData.length && new Date(sortedData[dataIndex].date) <= currentDate) {
33 currentCount = sortedData[dataIndex].count;
34 dataIndex++;
35 }
36 result.push({
37 date: currentDate.toISOString(),
38 count: currentCount
39 });
40 const nextDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 7);
41 if (nextDate.getMonth() !== currentDate.getMonth()) {
42 currentDate = new Date(nextDate.getFullYear(), nextDate.getMonth(), 1);
43 } else {
44 currentDate = nextDate;
45 }
46 }
47
48 return result;
49 };
50
51 const processedDataSingleplayer = React.useMemo(
52 () => processTimelineData(portalCountDataSingleplayer),
53 [portalCountDataSingleplayer]
54 );
55
56 const processedDataMultiplayer = React.useMemo(
57 () => processTimelineData(portalCountDataMultiplayer),
58 [portalCountDataMultiplayer]
59 );
60
61 const getYearlyTicks = (data: PortalCountData[]): string[] => {
62 if (data.length === 0) {
63 return [];
64 }
65 const seenYears = new Set<number>();
66 const ticks: string[] = [];
67 for (const point of data) {
68 const year = new Date(point.date).getFullYear();
69 if (!seenYears.has(year)) {
70 seenYears.add(year);
71 ticks.push(point.date);
72 }
73 }
74 return ticks;
75 };
76
77 const yearlyTicksSingleplayer = React.useMemo(
78 () => getYearlyTicks(processedDataSingleplayer),
79 [processedDataSingleplayer]
80 );
81
82 const yearlyTicksMultiplayer = React.useMemo(
83 () => getYearlyTicks(processedDataMultiplayer),
84 [processedDataMultiplayer]
85 );
86
87 const fetchPortalCountData = async () => {
88 setIsLoading(true);
89 const data = await API.get_portal_count_history();
90 setPortalCountDataSingleplayer(data?.timeline_singleplayer || []);
91 setPortalCountDataMultiplayer(data?.timeline_multiplayer || []);
92 setIsLoading(false);
93 };
94
95 const fetchRecentScores = async () => {
96 setIsLoadingScores(true);
97 const scores = await API.get_recent_scores();
98 setRecentScores(scores);
99 setIsLoadingScores(false);
100 };
101
102 React.useEffect(() => {
103 fetchPortalCountData();
104 fetchRecentScores();
105 }, []);
106
107 const CustomTooltip = ({ active, payload }: any) => {
108 if (active && payload && payload.length) {
109 return (
110 <div className="custom-tooltip">
111 <p className="tooltip-date">{new Date(payload[0].payload.date).toLocaleDateString("en-US", {
112 year: "numeric",
113 month: "long",
114 day: "numeric"
115 })}</p>
116 <p className="tooltip-count">{`Portal Count: ${payload[0].value}`}</p>
117 </div>
118 );
119 }
120 return null;
121 };
5 122
6 return ( 123 return (
7 <main> 124 <main className="homepage">
8 <Helmet> 125 <Helmet>
9 <title>LPHUB | Homepage</title> 126 <title>LPHUB | Homepage</title>
10 </Helmet> 127 </Helmet>
11 <section> 128
12 <p /> 129 <section className="hero-section">
13 <h1>Welcome to Least Portals Hub!</h1> 130 <div className="hero-content">
14 <p>At the moment, LPHUB is in beta state. This means that the site has only the core functionalities enabled for providing both collaborative information and competitive leaderboards.</p> 131 <h1 className="hero-title">Welcome to Least Portals Hub!</h1>
15 <p>The website should feel intuitive to navigate around. For any type of feedback, reach us at LPHUB Discord server.</p> 132 <p className="hero-subtitle">
16 <p>By using LPHUB, you agree that you have read the 'Leaderboard Rules' and the 'About LPHUB' pages.</p> 133 Your ultimate destination for Portal 2 Least Portals speedrunning.
134 </p>
135 </div>
17 </section> 136 </section>
137
138 <section className="stats-section">
139 <div className="stats-grid">
140 <div className="stats-container">
141 <div className="stats-header">
142 <h3>Least Portals World Record Timeline</h3>
143 </div>
144
145 {isLoading ? (
146 <div className="chart-loading">
147 <div className="loading-spinner"></div>
148 </div>
149 ) : (selectedMode === "singleplayer" ? processedDataSingleplayer : processedDataMultiplayer).length > 0 ? (
150 <>
151 <div className="chart-wrapper">
152 <ResponsiveContainer width="100%" height={400}>
153 <AreaChart
154 data={selectedMode === "singleplayer" ? processedDataSingleplayer : processedDataMultiplayer}
155 margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
156 >
157 <defs>
158 <linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
159 <stop offset="5%" stopColor="#CDCFDF" stopOpacity={0.6} />
160 <stop offset="95%" stopColor="#CDCFDF" stopOpacity={0.05} />
161 </linearGradient>
162 </defs>
163 <CartesianGrid strokeDasharray="3 3" stroke="#202232" opacity={0.5} />
164 <XAxis
165 dataKey="date"
166 stroke="#CDCFDF"
167 tick={{ fill: "#CDCFDF", fontFamily: "BarlowSemiCondensed-Regular" }}
168 ticks={selectedMode === "singleplayer" ? yearlyTicksSingleplayer : yearlyTicksMultiplayer}
169 tickFormatter={(date) => {
170 const d = new Date(date);
171 return d.getFullYear().toString();
172 }}
173 />
174 <YAxis
175 stroke="#CDCFDF"
176 tick={{ fill: "#CDCFDF", fontFamily: "BarlowSemiCondensed-Regular" }}
177 />
178 <Tooltip content={<CustomTooltip />} />
179 <Area
180 type="monotone"
181 dataKey="count"
182 stroke="#FFF"
183 strokeWidth={2}
184 fillOpacity={1}
185 fill="url(#colorCount)"
186 />
187 </AreaChart>
188 </ResponsiveContainer>
189 </div>
190 <div className="mode-toggle-container">
191 <button
192 onClick={() => setSelectedMode("singleplayer")}
193 className={`mode-toggle-button ${selectedMode === "singleplayer" ? "selected" : ""}`}
194 >
195 Singleplayer
196 </button>
197 <button
198 onClick={() => setSelectedMode("multiplayer")}
199 className={`mode-toggle-button ${selectedMode === "multiplayer" ? "selected" : ""}`}
200 >
201 Multiplayer
202 </button>
203 </div>
204 </>
205 ) : (
206 <div className="chart-empty">
207 <p>No data available yet.</p>
208 </div>
209 )}
210 </div>
211
212 <div className="recent-scores-container">
213 <div className="recent-scores-header">
214 <h3>Recent Scores</h3>
215 </div>
216
217 {isLoadingScores ? (
218 <div className="scores-loading">
219 <div className="loading-spinner"></div>
220 </div>
221 ) : recentScores.length > 0 ? (
222 <div className="recent-scores-list">
223 {recentScores.map((score, index) => (
224 <div key={index} className="score-item">
225 <div>
226 <Link key={index} to={`/users/${score.user.steam_id}`} className="score-user">{score.user.user_name}</Link>
227 </div>
228 <div className="score-map">
229 <Link key={index} to={`/maps/${score.map.id}`} className="score-map">{score.map.name}</Link>
230 </div>
231 <div className="score-portals">{score.score_count} { } portals</div>
232 </div>
233 ))}
234 </div>
235 ) : (
236 <div className="scores-empty">
237 <p>No Recent Scores.</p>
238 </div>
239 )}
240 </div>
241 </div>
242 </section>
243
18 </main> 244 </main>
19 ); 245 );
20}; 246};