From dd5ea1b1fcbb21c919a16bc70c6507b097c12f6b Mon Sep 17 00:00:00 2001 From: Arda Serdar Pektezol <1669855+pektezol@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:12:56 +0300 Subject: feat/frontend: homepage with timeline and recent scores --- frontend/src/api/Api.ts | 4 + frontend/src/api/Stats.ts | 52 ++++ frontend/src/css/Homepage.css | 545 ++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/Homepage.tsx | 240 +++++++++++++++++- 4 files changed, 834 insertions(+), 7 deletions(-) create mode 100644 frontend/src/api/Stats.ts create mode 100644 frontend/src/css/Homepage.css (limited to 'frontend/src') 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 import { get_official_rankings, get_unofficial_rankings } from "@api/Rankings"; import { 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"; import { delete_map_summary, post_map_summary, put_map_image, put_map_summary } from "@api/Mod"; +import { get_portal_count_history, get_recent_scores } from "@api/Stats"; import { UploadRunContent } from "@customTypes/Content"; // add new api call function entries here @@ -47,6 +48,9 @@ export const API = { put_map_summary: (token: string, map_id: string, content: ModMenuContent) => put_map_summary(token, map_id, content), delete_map_summary: (token: string, map_id: string, route_id: number) => delete_map_summary(token, map_id, route_id), + // Stats + get_portal_count_history: () => get_portal_count_history(), + get_recent_scores: () => get_recent_scores(), }; const 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 @@ +import axios from "axios"; +import { url } from "./Api"; + +export interface PortalCountData { + date: string; + count: number; +} + +export interface RecordsTimelineResponse { + timeline_singleplayer: PortalCountData[]; + timeline_multiplayer: PortalCountData[]; +} + +export interface ScoreLog { + game: { + id: number; + name: string; + image: string; + is_coop: boolean; + category_portals: null; + }; + user: { + steam_id: string; + user_name: string; + }; + map: { + id: number; + name: string; + image: string; + is_disabled: boolean; + portal_count: number; + difficulty: number; + }; + score_count: number; +} + +export async function get_portal_count_history(): Promise { + const response = await axios.get(url("stats/timeline")); + if (!response.data.data) { + return undefined; + } + return response.data.data; +} + +export async function get_recent_scores(): Promise { + const response = await axios.get(url("stats/scores")); + if (!response.data.data) { + return []; + } + return response.data.data.scores.slice(0, 5); +} + 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 @@ +.hero-section { + text-align: center; + padding: 20px 20px; + margin: 20px; + background: #202232; + border-radius: 24px; +} + +.hero-content { + max-width: 800px; + margin: 0 auto; +} + +.hero-title { + font-size: 56px; + font-family: BarlowCondensed-Bold; + color: #FFF; + margin-bottom: 20px; + line-height: 1.2; +} + +.hero-subtitle { + font-size: 24px; + font-family: BarlowSemiCondensed-Regular; + color: #CDCFDF; + margin: 0; +} + +.stats-section { + margin: 20px; +} + +.stats-grid { + display: grid; + grid-template-columns: 4fr 1fr; + gap: 20px; +} + +.stats-container { + background: #202232; + border-radius: 24px; + padding: 20px; +} + +.stats-header { + text-align: center; + margin-bottom: 30px; +} + +.stats-header h3 { + font-size: 32px; + font-family: BarlowCondensed-Bold; + color: #FFF; + margin-top: 0px; + margin-bottom: 10px; +} + +.stats-header p { + color: #CDCFDF; + font-size: 20px; + font-family: BarlowSemiCondensed-Regular; + margin: 0; +} + +/* Chart Wrapper */ +.chart-wrapper { + background: #2b2e46; + border-radius: 20px; + padding: 20px 10px; + margin-top: 20px; +} + +.chart-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #CDCFDF; +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 5px solid #FFF; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + margin-bottom: 20px; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.chart-loading p { + font-size: 20px; + font-family: BarlowSemiCondensed-Regular; + margin: 0; +} + +.chart-empty { + text-align: center; + padding: 60px 20px; + color: #CDCFDF; + font-size: 20px; + font-family: BarlowSemiCondensed-Regular; +} + +/* Custom Tooltip */ +.custom-tooltip { + background: #2b2e46; + border-radius: 12px; + padding: 12px 16px; +} + +.tooltip-date { + color: #CDCFDF; + font-family: BarlowSemiCondensed-Regular; + font-size: 16px; + margin: 0 0 4px 0; +} + +.tooltip-count { + color: #FFF; + font-family: BarlowSemiCondensed-SemiBold; + font-size: 22px; + margin: 0; +} + +/* Mode Toggle Buttons */ +.mode-toggle-container { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.mode-toggle-button { + background-color: #2b2e46; + padding: 10px 20px; + border: 0; + color: #cdcfdf; + cursor: pointer; + font-family: BarlowSemiCondensed-Regular; + font-size: 24px; + transition: all 0.1s; + flex: 1; + max-width: 150px; +} + +.mode-toggle-button:first-child { + border-radius: 5px 0 0 5px; +} + +.mode-toggle-button:last-child { + border-radius: 0 5px 5px 0; +} + +.mode-toggle-button:hover, +.mode-toggle-button.selected { + background-color: #202232; +} + +/* Recent Scores */ +.recent-scores-container { + background: #202232; + border-radius: 24px; + padding: 20px; + display: flex; + flex-direction: column; +} + +.recent-scores-header { + text-align: center; + margin-bottom: 20px; +} + +.recent-scores-header h3 { + font-size: 32px; + font-family: BarlowCondensed-Bold; + color: #FFF; + margin: 0; +} + +.scores-loading { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + padding: 40px 20px; +} + +.recent-scores-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.score-item { + background: #2b2e46; + border-radius: 16px; + padding: 12px 16px; + transition: background-color 0.15s; +} + +.score-item:hover { + background: #353854; +} + +.score-user { + font-family: BarlowSemiCondensed-SemiBold; + font-size: 18px; + color: #FFF; + margin-bottom: 4px; +} + +.score-map { + font-family: BarlowSemiCondensed-Regular; + font-size: 16px; + color: #CDCFDF; + margin-bottom: 2px; +} + +.score-portals { + font-family: BarlowSemiCondensed-Regular; + font-size: 14px; + color: #888; +} + +.scores-empty { + text-align: center; + padding: 40px 20px; + color: #CDCFDF; + font-size: 16px; + font-family: BarlowSemiCondensed-Regular; +} + +/* Info Section */ +.info-section { + margin: 20px; +} + +.info-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.info-card { + background: #202232; + border-radius: 24px; + padding: 30px; + text-align: center; + transition: background-color 0.15s; +} + +.info-card:hover { + background: #2b2e46; +} + +.info-icon { + font-size: 50px; + margin-bottom: 20px; +} + +.info-card h3 { + font-size: 32px; + font-family: BarlowCondensed-Bold; + color: #FFF; + margin-bottom: 15px; +} + +.info-card p { + color: #CDCFDF; + font-size: 18px; + font-family: BarlowSemiCondensed-Regular; + line-height: 1.6; + margin: 0; +} + +/* Notice Section */ +.notice-section { + background: #202232; + border-radius: 24px; + padding: 30px; + margin: 20px; +} + +.notice-content h3 { + color: #FFF; + font-size: 40px; + font-family: BarlowCondensed-Bold; + margin-top: 0; + margin-bottom: 20px; +} + +.notice-content p { + color: #CDCFDF; + font-size: 20px; + font-family: BarlowSemiCondensed-Regular; + line-height: 1.8; + margin-bottom: 15px; +} + +.notice-content p:last-child { + margin-bottom: 0; +} + +.notice-content strong { + color: #FFF; + font-family: BarlowSemiCondensed-SemiBold; +} + +/* Responsive Design */ +@media screen and (min-width: 769px) and (max-width: 1024px) { + .hero-section { + margin: 18px; + padding: 40px 18px; + } + + .hero-title { + font-size: 48px; + } + + .hero-subtitle { + font-size: 22px; + } + + .stats-section { + margin: 18px; + } + + .stats-grid { + gap: 18px; + } + + .stats-container { + padding: 18px; + } + + .stats-header h3 { + font-size: 44px; + } + + .stats-header p { + font-size: 18px; + } + + .chart-wrapper { + padding: 18px 10px; + } + + .recent-scores-container { + padding: 18px; + } + + .recent-scores-header h3 { + font-size: 28px; + } + + .score-user { + font-size: 16px; + } + + .score-map { + font-size: 14px; + } + + .score-portals { + font-size: 12px; + } + + .info-section { + margin: 18px; + } + + .info-cards { + gap: 18px; + } + + .info-card { + padding: 25px; + } + + .info-card h3 { + font-size: 28px; + } + + .info-card p { + font-size: 16px; + } + + .notice-section { + margin: 18px; + padding: 25px; + } + + .notice-content h3 { + font-size: 36px; + } + + .notice-content p { + font-size: 18px; + } +} + +@media screen and (max-width: 768px) { + .hero-section { + margin: 20px; + padding: 30px 20px; + } + + .hero-title { + font-size: 40px; + } + + .hero-subtitle { + font-size: 18px; + } + + .stats-section { + margin: 20px; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: 15px; + } + + .stats-container { + padding: 15px; + } + + .stats-header h3 { + font-size: 36px; + margin-bottom: 8px; + } + + .stats-header p { + font-size: 16px; + } + + .chart-wrapper { + padding: 15px 5px; + margin-top: 15px; + } + + .recent-scores-container { + padding: 15px; + } + + .recent-scores-header h3 { + font-size: 28px; + } + + .score-item { + padding: 10px 12px; + } + + .score-user { + font-size: 16px; + } + + .score-map { + font-size: 14px; + } + + .score-portals { + font-size: 12px; + } + + .chart-loading, + .chart-empty { + padding: 40px 15px; + } + + .chart-loading p, + .chart-empty p { + font-size: 16px; + } + + .loading-spinner { + width: 40px; + height: 40px; + border-width: 4px; + } + + .info-section { + margin: 20px; + } + + .info-cards { + grid-template-columns: 1fr; + gap: 15px; + } + + .info-card { + padding: 20px; + } + + .info-icon { + font-size: 40px; + margin-bottom: 15px; + } + + .info-card h3 { + font-size: 28px; + margin-bottom: 12px; + } + + .info-card p { + font-size: 16px; + } + + .notice-section { + margin: 20px; + padding: 20px; + } + + .notice-content h3 { + font-size: 32px; + margin-bottom: 15px; + } + + .notice-content p { + font-size: 16px; + margin-bottom: 12px; + } + + .tooltip-date { + font-size: 14px; + } + + .tooltip-count { + font-size: 18px; + } +} \ 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 @@ import React from "react"; import { Helmet } from "react-helmet"; +import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from "recharts"; +import { API } from "../api/Api"; +import { PortalCountData, ScoreLog } from "../api/Stats"; +import "../css/Homepage.css"; +import { Link } from "react-router-dom"; const Homepage: React.FC = () => { + const [portalCountDataSingleplayer, setPortalCountDataSingleplayer] = React.useState([]); + const [portalCountDataMultiplayer, setPortalCountDataMultiplayer] = React.useState([]); + const [recentScores, setRecentScores] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [isLoadingScores, setIsLoadingScores] = React.useState(true); + const [selectedMode, setSelectedMode] = React.useState<"singleplayer" | "multiplayer">("singleplayer"); + + const processTimelineData = (data: PortalCountData[]): PortalCountData[] => { + if (data.length === 0) { + return []; + }; + const sortedData = [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const startDate = new Date(sortedData[0].date); + const endDate = new Date(sortedData[sortedData.length - 1].date); + + const result: PortalCountData[] = []; + let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + + let dataIndex = 0; + let currentCount = sortedData[0].count; + + while (currentDate <= endDate) { + while (dataIndex < sortedData.length && new Date(sortedData[dataIndex].date) <= currentDate) { + currentCount = sortedData[dataIndex].count; + dataIndex++; + } + result.push({ + date: currentDate.toISOString(), + count: currentCount + }); + const nextDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate() + 7); + if (nextDate.getMonth() !== currentDate.getMonth()) { + currentDate = new Date(nextDate.getFullYear(), nextDate.getMonth(), 1); + } else { + currentDate = nextDate; + } + } + + return result; + }; + + const processedDataSingleplayer = React.useMemo( + () => processTimelineData(portalCountDataSingleplayer), + [portalCountDataSingleplayer] + ); + + const processedDataMultiplayer = React.useMemo( + () => processTimelineData(portalCountDataMultiplayer), + [portalCountDataMultiplayer] + ); + + const getYearlyTicks = (data: PortalCountData[]): string[] => { + if (data.length === 0) { + return []; + } + const seenYears = new Set(); + const ticks: string[] = []; + for (const point of data) { + const year = new Date(point.date).getFullYear(); + if (!seenYears.has(year)) { + seenYears.add(year); + ticks.push(point.date); + } + } + return ticks; + }; + + const yearlyTicksSingleplayer = React.useMemo( + () => getYearlyTicks(processedDataSingleplayer), + [processedDataSingleplayer] + ); + + const yearlyTicksMultiplayer = React.useMemo( + () => getYearlyTicks(processedDataMultiplayer), + [processedDataMultiplayer] + ); + + const fetchPortalCountData = async () => { + setIsLoading(true); + const data = await API.get_portal_count_history(); + setPortalCountDataSingleplayer(data?.timeline_singleplayer || []); + setPortalCountDataMultiplayer(data?.timeline_multiplayer || []); + setIsLoading(false); + }; + + const fetchRecentScores = async () => { + setIsLoadingScores(true); + const scores = await API.get_recent_scores(); + setRecentScores(scores); + setIsLoadingScores(false); + }; + + React.useEffect(() => { + fetchPortalCountData(); + fetchRecentScores(); + }, []); + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

{new Date(payload[0].payload.date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + })}

+

{`Portal Count: ${payload[0].value}`}

+
+ ); + } + return null; + }; return ( -
+
LPHUB | Homepage -
-

-

Welcome to Least Portals Hub!

-

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.

-

The website should feel intuitive to navigate around. For any type of feedback, reach us at LPHUB Discord server.

-

By using LPHUB, you agree that you have read the 'Leaderboard Rules' and the 'About LPHUB' pages.

+ +
+
+

Welcome to Least Portals Hub!

+

+ Your ultimate destination for Portal 2 Least Portals speedrunning. +

+
+ +
+
+
+
+

Least Portals World Record Timeline

+
+ + {isLoading ? ( +
+
+
+ ) : (selectedMode === "singleplayer" ? processedDataSingleplayer : processedDataMultiplayer).length > 0 ? ( + <> +
+ + + + + + + + + + { + const d = new Date(date); + return d.getFullYear().toString(); + }} + /> + + } /> + + + +
+
+ + +
+ + ) : ( +
+

No data available yet.

+
+ )} +
+ +
+
+

Recent Scores

+
+ + {isLoadingScores ? ( +
+
+
+ ) : recentScores.length > 0 ? ( +
+ {recentScores.map((score, index) => ( +
+
+ {score.user.user_name} +
+
+ {score.map.name} +
+
{score.score_count} { } portals
+
+ ))} +
+ ) : ( +
+

No Recent Scores.

+
+ )} +
+
+
+
); }; -- cgit v1.2.3