From 396db58adc16c9a4bd135faf1b295c200c3ab328 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Fri, 12 Jun 2026 01:34:13 -0700 Subject: show acc, combo, wpm on leaderboard --- src/app/game/[slug]/components/PreGameView.tsx | 137 +---------------- src/app/game/[slug]/components/ResultsView.tsx | 25 ++- .../game/[slug]/components/ScoreLeaderboard.tsx | 171 +++++++++++++++++++++ src/app/game/[slug]/page.styles.ts | 32 +++- 4 files changed, 225 insertions(+), 140 deletions(-) create mode 100644 src/app/game/[slug]/components/ScoreLeaderboard.tsx diff --git a/src/app/game/[slug]/components/PreGameView.tsx b/src/app/game/[slug]/components/PreGameView.tsx index 2cf417d..be6b0c6 100644 --- a/src/app/game/[slug]/components/PreGameView.tsx +++ b/src/app/game/[slug]/components/PreGameView.tsx @@ -1,7 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; -import pb from "../../../lib/pocketbase"; import { StartOverlay, PreGameCard, @@ -18,16 +16,8 @@ import { PreviewWrap, PreviewBtn, PreviewHint, - LeaderboardCard, - LeaderboardHeader, - LeaderboardTitle, - LeaderboardCount, - LeaderboardList, - LeaderboardRow, - LeaderboardRank, - LeaderboardName, - LeaderboardScore, } from "../page.styles"; +import ScoreLeaderboard from "./ScoreLeaderboard"; interface PreGameViewProps { isReady: boolean; @@ -47,42 +37,6 @@ interface PreGameViewProps { onPreviewToggle: () => void; } -interface LeaderboardEntry { - id: string; - name: string; - score: number; -} - -type ScoreRecord = Record & { - expand?: { - user?: unknown; - }; -}; - -function hasChartRelation(chartValue: unknown, chartId: string): boolean { - if (typeof chartValue === "string") return chartValue === chartId; - if (Array.isArray(chartValue)) return chartValue.includes(chartId); - return false; -} - -function extractUserName(record: ScoreRecord): string { - const expandedUser = record.expand?.user; - const resolvedExpandedUser = Array.isArray(expandedUser) - ? expandedUser[0] - : expandedUser; - - if (resolvedExpandedUser && typeof resolvedExpandedUser === "object") { - const user = resolvedExpandedUser as Record; - const username = typeof user.username === "string" ? user.username.trim() : ""; - const name = typeof user.name === "string" ? user.name.trim() : ""; - - if (username) return username; - if (name) return name; - } - - return "Unknown"; -} - export default function PreGameView({ isReady, loadingLrc, @@ -100,66 +54,6 @@ export default function PreGameView({ onStart, onPreviewToggle, }: PreGameViewProps) { - const [leaderboardEntries, setLeaderboardEntries] = useState([]); - const [loadingLeaderboard, setLoadingLeaderboard] = useState(false); - - useEffect(() => { - if (!chartId) { - setLeaderboardEntries([]); - return; - } - - let cancelled = false; - - const loadLeaderboard = async () => { - setLoadingLeaderboard(true); - try { - const records = await pb - .collection("scores") - .getFullList({ sort: "-score", expand: "user" }); - - if (cancelled) return; - - const bestScores = new Map(); - - for (const record of records) { - if (!hasChartRelation(record.chart, chartId)) continue; - - const score = Number(record.score); - if (!Number.isFinite(score)) continue; - - const name = extractUserName(record); - - const existing = bestScores.get(name); - - if (!existing || score > existing.score) { - bestScores.set(name, { - id: String(record.id), - name, - score, - }); - } - } - - const topEntries = Array.from(bestScores.values()) - .sort((a, b) => b.score - a.score) - .slice(0, 10); - - setLeaderboardEntries(topEntries); - } catch { - if (!cancelled) setLeaderboardEntries([]); - } finally { - if (!cancelled) setLoadingLeaderboard(false); - } - }; - - void loadLeaderboard(); - - return () => { - cancelled = true; - }; - }, [chartId]); - return ( @@ -225,34 +119,7 @@ export default function PreGameView({ - - - Leaderboard - - {leaderboardEntries.length - ? `Top ${leaderboardEntries.length}` - : "Top 0"} - - - - {!chartId ? ( - Leaderboard unavailable for this chart. - ) : loadingLeaderboard ? ( - Loading leaderboard... - ) : leaderboardEntries.length === 0 ? ( - No scores yet. Be the first! - ) : ( - - {leaderboardEntries.map((entry, index) => ( - - #{index + 1} - {entry.name} - {entry.score.toLocaleString()} - - ))} - - )} - + diff --git a/src/app/game/[slug]/components/ResultsView.tsx b/src/app/game/[slug]/components/ResultsView.tsx index df9d72c..1804b8d 100644 --- a/src/app/game/[slug]/components/ResultsView.tsx +++ b/src/app/game/[slug]/components/ResultsView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "react-toastify"; import { @@ -15,7 +15,9 @@ import { ActionRow, PlayAgainBtn, HomeBtn, + PreviewHint, } from "../page.styles"; +import ScoreLeaderboard from "./ScoreLeaderboard"; import { GState } from "../game.stat"; import { useAuth } from "../../../context/auth"; import pb from "../../../lib/pocketbase"; @@ -40,6 +42,8 @@ export default function ResultsView({ const router = useRouter(); const { user, loading } = useAuth(); const hasTriedAutoUploadRef = useRef(false); + const [isUploadingScore, setIsUploadingScore] = useState(false); + const [isUploadFlowComplete, setIsUploadFlowComplete] = useState(false); useEffect(() => { if (hasTriedAutoUploadRef.current) return; @@ -47,13 +51,20 @@ export default function ResultsView({ hasTriedAutoUploadRef.current = true; - if (!user || !chartId) return; + if (!user || !chartId) { + setIsUploadFlowComplete(true); + return; + } const dedupeKey = `scores:auto:${chartId}:${user.id}:${g.score}:${wpm}:${g.maxCombo}:${g.totalMiss}:${accuracy}`; - if (sessionStorage.getItem(dedupeKey) === "1") return; + if (sessionStorage.getItem(dedupeKey) === "1") { + setIsUploadFlowComplete(true); + return; + } const uploadScore = async () => { + setIsUploadingScore(true); try { try { await pb.collection("scores").create({ @@ -81,6 +92,9 @@ export default function ResultsView({ toast.success("Score uploaded.", { theme: "dark" }); } catch { toast.error("Failed to upload score. Please try again.", { theme: "dark" }); + } finally { + setIsUploadingScore(false); + setIsUploadFlowComplete(true); } }; @@ -110,6 +124,11 @@ export default function ResultsView({ Missed Chars + {isUploadingScore ? ( + Uploading score... + ) : isUploadFlowComplete ? ( + + ) : null} Play Again router.push("/")}>Home diff --git a/src/app/game/[slug]/components/ScoreLeaderboard.tsx b/src/app/game/[slug]/components/ScoreLeaderboard.tsx new file mode 100644 index 0000000..958ba30 --- /dev/null +++ b/src/app/game/[slug]/components/ScoreLeaderboard.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import pb from "../../../lib/pocketbase"; +import { + LeaderboardCard, + LeaderboardHeader, + LeaderboardTitle, + LeaderboardCount, + LeaderboardList, + LeaderboardRow, + LeaderboardRank, + LeaderboardEntryBody, + LeaderboardEntryTop, + LeaderboardName, + LeaderboardScore, + LeaderboardMetrics, + LeaderboardMetric, + PreviewHint, +} from "../page.styles"; + +interface ScoreLeaderboardProps { + chartId: string | null; +} + +interface LeaderboardEntry { + id: string; + name: string; + score: number; + accuracy: number; + combo: number; + wpm: number; +} + +type ScoreRecord = Record & { + expand?: { + user?: unknown; + }; +}; + +function hasChartRelation(chartValue: unknown, chartId: string): boolean { + if (typeof chartValue === "string") return chartValue === chartId; + if (Array.isArray(chartValue)) return chartValue.includes(chartId); + return false; +} + +function extractUserName(record: ScoreRecord): string { + const expandedUser = record.expand?.user; + + const resolvedExpandedUser = Array.isArray(expandedUser) + ? expandedUser[0] + : expandedUser; + + if (resolvedExpandedUser && typeof resolvedExpandedUser === "object") { + const user = resolvedExpandedUser as Record; + const username = typeof user.username === "string" ? user.username.trim() : ""; + const name = typeof user.name === "string" ? user.name.trim() : ""; + + if (username) return username; + if (name) return name; + } + + return "Unknown"; +} + +function asFiniteNumber(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +export default function ScoreLeaderboard({ chartId }: ScoreLeaderboardProps) { + const [leaderboardEntries, setLeaderboardEntries] = useState([]); + const [loadingLeaderboard, setLoadingLeaderboard] = useState(false); + + useEffect(() => { + if (!chartId) { + setLeaderboardEntries([]); + return; + } + + let cancelled = false; + + const loadLeaderboard = async () => { + setLoadingLeaderboard(true); + try { + const records = await pb + .collection("scores") + .getFullList({ sort: "-score", expand: "user" }); + + if (cancelled) return; + + const bestScores = new Map(); + + for (const record of records) { + if (!hasChartRelation(record.chart, chartId)) continue; + + const score = asFiniteNumber(record.score); + if (!Number.isFinite(score)) continue; + + const name = extractUserName(record); + const existing = bestScores.get(name); + + if (!existing || score > existing.score) { + bestScores.set(name, { + id: String(record.id), + name, + score, + accuracy: asFiniteNumber(record.accuracy), + combo: asFiniteNumber(record.combo), + wpm: asFiniteNumber(record.wpm), + }); + } + } + + const topEntries = Array.from(bestScores.values()) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + setLeaderboardEntries(topEntries); + } catch { + if (!cancelled) setLeaderboardEntries([]); + } finally { + if (!cancelled) setLoadingLeaderboard(false); + } + }; + + void loadLeaderboard(); + + return () => { + cancelled = true; + }; + }, [chartId]); + + return ( + + + Leaderboard + + {leaderboardEntries.length ? `Top ${leaderboardEntries.length}` : "Top 0"} + + + + {!chartId ? ( + Leaderboard unavailable for this chart. + ) : loadingLeaderboard ? ( + Loading leaderboard... + ) : leaderboardEntries.length === 0 ? ( + No scores yet. Be the first! + ) : ( + + {leaderboardEntries.map((entry, index) => ( + + #{index + 1} + + + {entry.name} + {entry.score.toLocaleString()} + + + Acc: {entry.accuracy}% + Combo: x{entry.combo} + WPM: {entry.wpm} + + + + ))} + + )} + + ); +} diff --git a/src/app/game/[slug]/page.styles.ts b/src/app/game/[slug]/page.styles.ts index 38411ea..ec6ebda 100644 --- a/src/app/game/[slug]/page.styles.ts +++ b/src/app/game/[slug]/page.styles.ts @@ -462,8 +462,8 @@ export const LeaderboardList = styled.div` export const LeaderboardRow = styled.div` display: grid; - grid-template-columns: 42px minmax(0, 1fr) auto; - align-items: center; + grid-template-columns: 42px minmax(0, 1fr); + align-items: start; gap: 10px; padding: 8px 10px; border: 1px solid rgba(255, 255, 255, 0.12); @@ -474,6 +474,21 @@ export const LeaderboardRank = styled.span` color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; font-size: 13px; + padding-top: 2px; +`; + +export const LeaderboardEntryBody = styled.div` + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const LeaderboardEntryTop = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; `; export const LeaderboardName = styled.span` @@ -489,6 +504,19 @@ export const LeaderboardScore = styled.span` font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; + white-space: nowrap; +`; + +export const LeaderboardMetrics = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px 8px; +`; + +export const LeaderboardMetric = styled.span` + color: rgba(255, 255, 255, 0.62); + font-size: 11px; + font-variant-numeric: tabular-nums; `; export const OpacityControl = styled.div` -- cgit v1.2.3