aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/pages/Homepage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/pages/Homepage.tsx')
-rw-r--r--frontend/src/pages/Homepage.tsx240
1 files changed, 233 insertions, 7 deletions
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};