aboutsummaryrefslogtreecommitdiff
path: root/frontend
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
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 '')
-rw-r--r--frontend/package-lock.json347
-rw-r--r--frontend/package.json5
-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
6 files changed, 1183 insertions, 10 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cc1e3e8..7a122c1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,7 +17,8 @@
17 "react-dom": "^18.3.1", 17 "react-dom": "^18.3.1",
18 "react-helmet": "^6.1.0", 18 "react-helmet": "^6.1.0",
19 "react-markdown": "^9.0.1", 19 "react-markdown": "^9.0.1",
20 "react-router-dom": "^6.26.1" 20 "react-router-dom": "^6.26.1",
21 "recharts": "^3.3.0"
21 }, 22 },
22 "devDependencies": { 23 "devDependencies": {
23 "@eslint/js": "^9.38.0", 24 "@eslint/js": "^9.38.0",
@@ -900,6 +901,31 @@
900 "node": ">= 8" 901 "node": ">= 8"
901 } 902 }
902 }, 903 },
904 "node_modules/@reduxjs/toolkit": {
905 "version": "2.9.2",
906 "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz",
907 "integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==",
908 "dependencies": {
909 "@standard-schema/spec": "^1.0.0",
910 "@standard-schema/utils": "^0.3.0",
911 "immer": "^10.0.3",
912 "redux": "^5.0.1",
913 "redux-thunk": "^3.1.0",
914 "reselect": "^5.1.0"
915 },
916 "peerDependencies": {
917 "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
918 "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
919 },
920 "peerDependenciesMeta": {
921 "react": {
922 "optional": true
923 },
924 "react-redux": {
925 "optional": true
926 }
927 }
928 },
903 "node_modules/@remix-run/router": { 929 "node_modules/@remix-run/router": {
904 "version": "1.23.0", 930 "version": "1.23.0",
905 "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", 931 "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@@ -1224,6 +1250,16 @@
1224 "win32" 1250 "win32"
1225 ] 1251 ]
1226 }, 1252 },
1253 "node_modules/@standard-schema/spec": {
1254 "version": "1.0.0",
1255 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
1256 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
1257 },
1258 "node_modules/@standard-schema/utils": {
1259 "version": "0.3.0",
1260 "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1261 "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
1262 },
1227 "node_modules/@types/babel__core": { 1263 "node_modules/@types/babel__core": {
1228 "version": "7.20.5", 1264 "version": "7.20.5",
1229 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", 1265 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1269,6 +1305,60 @@
1269 "@babel/types": "^7.28.2" 1305 "@babel/types": "^7.28.2"
1270 } 1306 }
1271 }, 1307 },
1308 "node_modules/@types/d3-array": {
1309 "version": "3.2.2",
1310 "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1311 "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
1312 },
1313 "node_modules/@types/d3-color": {
1314 "version": "3.1.3",
1315 "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1316 "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
1317 },
1318 "node_modules/@types/d3-ease": {
1319 "version": "3.0.2",
1320 "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1321 "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
1322 },
1323 "node_modules/@types/d3-interpolate": {
1324 "version": "3.0.4",
1325 "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1326 "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1327 "dependencies": {
1328 "@types/d3-color": "*"
1329 }
1330 },
1331 "node_modules/@types/d3-path": {
1332 "version": "3.1.1",
1333 "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1334 "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
1335 },
1336 "node_modules/@types/d3-scale": {
1337 "version": "4.0.9",
1338 "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1339 "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1340 "dependencies": {
1341 "@types/d3-time": "*"
1342 }
1343 },
1344 "node_modules/@types/d3-shape": {
1345 "version": "3.1.7",
1346 "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
1347 "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
1348 "dependencies": {
1349 "@types/d3-path": "*"
1350 }
1351 },
1352 "node_modules/@types/d3-time": {
1353 "version": "3.0.4",
1354 "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1355 "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
1356 },
1357 "node_modules/@types/d3-timer": {
1358 "version": "3.0.2",
1359 "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1360 "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
1361 },
1272 "node_modules/@types/debug": { 1362 "node_modules/@types/debug": {
1273 "version": "4.1.12", 1363 "version": "4.1.12",
1274 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", 1364 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -1367,6 +1457,11 @@
1367 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 1457 "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
1368 "license": "MIT" 1458 "license": "MIT"
1369 }, 1459 },
1460 "node_modules/@types/use-sync-external-store": {
1461 "version": "0.0.6",
1462 "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
1463 "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
1464 },
1370 "node_modules/@typescript-eslint/eslint-plugin": { 1465 "node_modules/@typescript-eslint/eslint-plugin": {
1371 "version": "8.46.2", 1466 "version": "8.46.2",
1372 "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", 1467 "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
@@ -1964,6 +2059,14 @@
1964 "url": "https://github.com/sponsors/wooorm" 2059 "url": "https://github.com/sponsors/wooorm"
1965 } 2060 }
1966 }, 2061 },
2062 "node_modules/clsx": {
2063 "version": "2.1.1",
2064 "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
2065 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
2066 "engines": {
2067 "node": ">=6"
2068 }
2069 },
1967 "node_modules/color-convert": { 2070 "node_modules/color-convert": {
1968 "version": "2.0.1", 2071 "version": "2.0.1",
1969 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 2072 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2041,6 +2144,116 @@
2041 "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 2144 "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
2042 "license": "MIT" 2145 "license": "MIT"
2043 }, 2146 },
2147 "node_modules/d3-array": {
2148 "version": "3.2.4",
2149 "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
2150 "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
2151 "dependencies": {
2152 "internmap": "1 - 2"
2153 },
2154 "engines": {
2155 "node": ">=12"
2156 }
2157 },
2158 "node_modules/d3-color": {
2159 "version": "3.1.0",
2160 "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
2161 "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
2162 "engines": {
2163 "node": ">=12"
2164 }
2165 },
2166 "node_modules/d3-ease": {
2167 "version": "3.0.1",
2168 "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
2169 "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
2170 "engines": {
2171 "node": ">=12"
2172 }
2173 },
2174 "node_modules/d3-format": {
2175 "version": "3.1.0",
2176 "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
2177 "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
2178 "engines": {
2179 "node": ">=12"
2180 }
2181 },
2182 "node_modules/d3-interpolate": {
2183 "version": "3.0.1",
2184 "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
2185 "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
2186 "dependencies": {
2187 "d3-color": "1 - 3"
2188 },
2189 "engines": {
2190 "node": ">=12"
2191 }
2192 },
2193 "node_modules/d3-path": {
2194 "version": "3.1.0",
2195 "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
2196 "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
2197 "engines": {
2198 "node": ">=12"
2199 }
2200 },
2201 "node_modules/d3-scale": {
2202 "version": "4.0.2",
2203 "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
2204 "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
2205 "dependencies": {
2206 "d3-array": "2.10.0 - 3",
2207 "d3-format": "1 - 3",
2208 "d3-interpolate": "1.2.0 - 3",
2209 "d3-time": "2.1.1 - 3",
2210 "d3-time-format": "2 - 4"
2211 },
2212 "engines": {
2213 "node": ">=12"
2214 }
2215 },
2216 "node_modules/d3-shape": {
2217 "version": "3.2.0",
2218 "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
2219 "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
2220 "dependencies": {
2221 "d3-path": "^3.1.0"
2222 },
2223 "engines": {
2224 "node": ">=12"
2225 }
2226 },
2227 "node_modules/d3-time": {
2228 "version": "3.1.0",
2229 "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
2230 "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
2231 "dependencies": {
2232 "d3-array": "2 - 3"
2233 },
2234 "engines": {
2235 "node": ">=12"
2236 }
2237 },
2238 "node_modules/d3-time-format": {
2239 "version": "4.1.0",
2240 "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
2241 "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
2242 "dependencies": {
2243 "d3-time": "1 - 3"
2244 },
2245 "engines": {
2246 "node": ">=12"
2247 }
2248 },
2249 "node_modules/d3-timer": {
2250 "version": "3.0.1",
2251 "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
2252 "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
2253 "engines": {
2254 "node": ">=12"
2255 }
2256 },
2044 "node_modules/debug": { 2257 "node_modules/debug": {
2045 "version": "4.4.3", 2258 "version": "4.4.3",
2046 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 2259 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2058,6 +2271,11 @@
2058 } 2271 }
2059 } 2272 }
2060 }, 2273 },
2274 "node_modules/decimal.js-light": {
2275 "version": "2.5.1",
2276 "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
2277 "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
2278 },
2061 "node_modules/decode-named-character-reference": { 2279 "node_modules/decode-named-character-reference": {
2062 "version": "1.2.0", 2280 "version": "1.2.0",
2063 "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", 2281 "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -2188,6 +2406,11 @@
2188 "node": ">= 0.4" 2406 "node": ">= 0.4"
2189 } 2407 }
2190 }, 2408 },
2409 "node_modules/es-toolkit": {
2410 "version": "1.41.0",
2411 "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz",
2412 "integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="
2413 },
2191 "node_modules/esbuild": { 2414 "node_modules/esbuild": {
2192 "version": "0.21.5", 2415 "version": "0.21.5",
2193 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 2416 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -2421,6 +2644,11 @@
2421 "node": ">=0.10.0" 2644 "node": ">=0.10.0"
2422 } 2645 }
2423 }, 2646 },
2647 "node_modules/eventemitter3": {
2648 "version": "5.0.1",
2649 "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
2650 "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
2651 },
2424 "node_modules/extend": { 2652 "node_modules/extend": {
2425 "version": "3.0.2", 2653 "version": "3.0.2",
2426 "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", 2654 "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -2846,6 +3074,15 @@
2846 "node": ">= 4" 3074 "node": ">= 4"
2847 } 3075 }
2848 }, 3076 },
3077 "node_modules/immer": {
3078 "version": "10.2.0",
3079 "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
3080 "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
3081 "funding": {
3082 "type": "opencollective",
3083 "url": "https://opencollective.com/immer"
3084 }
3085 },
2849 "node_modules/import-fresh": { 3086 "node_modules/import-fresh": {
2850 "version": "3.3.1", 3087 "version": "3.3.1",
2851 "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 3088 "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2898,6 +3135,14 @@
2898 "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", 3135 "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
2899 "license": "MIT" 3136 "license": "MIT"
2900 }, 3137 },
3138 "node_modules/internmap": {
3139 "version": "2.0.3",
3140 "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
3141 "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
3142 "engines": {
3143 "node": ">=12"
3144 }
3145 },
2901 "node_modules/is-alphabetical": { 3146 "node_modules/is-alphabetical": {
2902 "version": "2.0.1", 3147 "version": "2.0.1",
2903 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", 3148 "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -4183,6 +4428,28 @@
4183 "react": ">=18" 4428 "react": ">=18"
4184 } 4429 }
4185 }, 4430 },
4431 "node_modules/react-redux": {
4432 "version": "9.2.0",
4433 "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
4434 "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
4435 "dependencies": {
4436 "@types/use-sync-external-store": "^0.0.6",
4437 "use-sync-external-store": "^1.4.0"
4438 },
4439 "peerDependencies": {
4440 "@types/react": "^18.2.25 || ^19",
4441 "react": "^18.0 || ^19",
4442 "redux": "^5.0.0"
4443 },
4444 "peerDependenciesMeta": {
4445 "@types/react": {
4446 "optional": true
4447 },
4448 "redux": {
4449 "optional": true
4450 }
4451 }
4452 },
4186 "node_modules/react-refresh": { 4453 "node_modules/react-refresh": {
4187 "version": "0.17.0", 4454 "version": "0.17.0",
4188 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", 4455 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4234,6 +4501,45 @@
4234 "react": "^16.3.0 || ^17.0.0 || ^18.0.0" 4501 "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
4235 } 4502 }
4236 }, 4503 },
4504 "node_modules/recharts": {
4505 "version": "3.3.0",
4506 "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
4507 "integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
4508 "dependencies": {
4509 "@reduxjs/toolkit": "1.x.x || 2.x.x",
4510 "clsx": "^2.1.1",
4511 "decimal.js-light": "^2.5.1",
4512 "es-toolkit": "^1.39.3",
4513 "eventemitter3": "^5.0.1",
4514 "immer": "^10.1.1",
4515 "react-redux": "8.x.x || 9.x.x",
4516 "reselect": "5.1.1",
4517 "tiny-invariant": "^1.3.3",
4518 "use-sync-external-store": "^1.2.2",
4519 "victory-vendor": "^37.0.2"
4520 },
4521 "engines": {
4522 "node": ">=18"
4523 },
4524 "peerDependencies": {
4525 "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
4526 "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
4527 "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
4528 }
4529 },
4530 "node_modules/redux": {
4531 "version": "5.0.1",
4532 "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
4533 "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
4534 },
4535 "node_modules/redux-thunk": {
4536 "version": "3.1.0",
4537 "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
4538 "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
4539 "peerDependencies": {
4540 "redux": "^5.0.0"
4541 }
4542 },
4237 "node_modules/remark-parse": { 4543 "node_modules/remark-parse": {
4238 "version": "11.0.0", 4544 "version": "11.0.0",
4239 "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", 4545 "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -4267,6 +4573,11 @@
4267 "url": "https://opencollective.com/unified" 4573 "url": "https://opencollective.com/unified"
4268 } 4574 }
4269 }, 4575 },
4576 "node_modules/reselect": {
4577 "version": "5.1.1",
4578 "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
4579 "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
4580 },
4270 "node_modules/resolve-from": { 4581 "node_modules/resolve-from": {
4271 "version": "4.0.0", 4582 "version": "4.0.0",
4272 "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", 4583 "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4511,6 +4822,11 @@
4511 "dev": true, 4822 "dev": true,
4512 "license": "MIT" 4823 "license": "MIT"
4513 }, 4824 },
4825 "node_modules/tiny-invariant": {
4826 "version": "1.3.3",
4827 "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
4828 "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
4829 },
4514 "node_modules/to-regex-range": { 4830 "node_modules/to-regex-range": {
4515 "version": "5.0.1", 4831 "version": "5.0.1",
4516 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", 4832 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -4755,6 +5071,14 @@
4755 "punycode": "^2.1.0" 5071 "punycode": "^2.1.0"
4756 } 5072 }
4757 }, 5073 },
5074 "node_modules/use-sync-external-store": {
5075 "version": "1.6.0",
5076 "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
5077 "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
5078 "peerDependencies": {
5079 "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5080 }
5081 },
4758 "node_modules/vfile": { 5082 "node_modules/vfile": {
4759 "version": "6.0.3", 5083 "version": "6.0.3",
4760 "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", 5084 "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -4783,6 +5107,27 @@
4783 "url": "https://opencollective.com/unified" 5107 "url": "https://opencollective.com/unified"
4784 } 5108 }
4785 }, 5109 },
5110 "node_modules/victory-vendor": {
5111 "version": "37.3.6",
5112 "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
5113 "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
5114 "dependencies": {
5115 "@types/d3-array": "^3.0.3",
5116 "@types/d3-ease": "^3.0.0",
5117 "@types/d3-interpolate": "^3.0.1",
5118 "@types/d3-scale": "^4.0.2",
5119 "@types/d3-shape": "^3.1.0",
5120 "@types/d3-time": "^3.0.0",
5121 "@types/d3-timer": "^3.0.0",
5122 "d3-array": "^3.1.6",
5123 "d3-ease": "^3.0.1",
5124 "d3-interpolate": "^3.0.1",
5125 "d3-scale": "^4.0.2",
5126 "d3-shape": "^3.1.0",
5127 "d3-time": "^3.0.0",
5128 "d3-timer": "^3.0.1"
5129 }
5130 },
4786 "node_modules/vite": { 5131 "node_modules/vite": {
4787 "version": "5.4.21", 5132 "version": "5.4.21",
4788 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", 5133 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 946ee40..46bb5ad 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,8 @@
13 "react-dom": "^18.3.1", 13 "react-dom": "^18.3.1",
14 "react-helmet": "^6.1.0", 14 "react-helmet": "^6.1.0",
15 "react-markdown": "^9.0.1", 15 "react-markdown": "^9.0.1",
16 "react-router-dom": "^6.26.1" 16 "react-router-dom": "^6.26.1",
17 "recharts": "^3.3.0"
17 }, 18 },
18 "scripts": { 19 "scripts": {
19 "dev": "vite", 20 "dev": "vite",
@@ -32,4 +33,4 @@
32 "typescript-eslint": "^8.46.2", 33 "typescript-eslint": "^8.46.2",
33 "vite": "^5.4.2" 34 "vite": "^5.4.2"
34 } 35 }
35} \ No newline at end of file 36}
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};