aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-12 01:34:13 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-12 01:34:13 -0700
commit396db58adc16c9a4bd135faf1b295c200c3ab328 (patch)
treece90ab082716d74cc58f10db020ab3668e5f88fe
parent5f05ac861a66f4a67735f1f06d91f67f1dca71d2 (diff)
show acc, combo, wpm on leaderboard
-rw-r--r--src/app/game/[slug]/components/PreGameView.tsx137
-rw-r--r--src/app/game/[slug]/components/ResultsView.tsx25
-rw-r--r--src/app/game/[slug]/components/ScoreLeaderboard.tsx171
-rw-r--r--src/app/game/[slug]/page.styles.ts32
4 files changed, 225 insertions, 140 deletions
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<string, unknown> & {
- 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<string, unknown>;
- 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<LeaderboardEntry[]>([]);
- 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<ScoreRecord>({ sort: "-score", expand: "user" });
-
- if (cancelled) return;
-
- const bestScores = new Map<string, LeaderboardEntry>();
-
- 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 (
<StartOverlay>
<PreGameCard>
@@ -225,34 +119,7 @@ export default function PreGameView({
</PreGameLeft>
<PreGameRight>
- <LeaderboardCard>
- <LeaderboardHeader>
- <LeaderboardTitle>Leaderboard</LeaderboardTitle>
- <LeaderboardCount>
- {leaderboardEntries.length
- ? `Top ${leaderboardEntries.length}`
- : "Top 0"}
- </LeaderboardCount>
- </LeaderboardHeader>
-
- {!chartId ? (
- <PreviewHint>Leaderboard unavailable for this chart.</PreviewHint>
- ) : loadingLeaderboard ? (
- <PreviewHint>Loading leaderboard...</PreviewHint>
- ) : leaderboardEntries.length === 0 ? (
- <PreviewHint>No scores yet. Be the first!</PreviewHint>
- ) : (
- <LeaderboardList>
- {leaderboardEntries.map((entry, index) => (
- <LeaderboardRow key={entry.id}>
- <LeaderboardRank>#{index + 1}</LeaderboardRank>
- <LeaderboardName>{entry.name}</LeaderboardName>
- <LeaderboardScore>{entry.score.toLocaleString()}</LeaderboardScore>
- </LeaderboardRow>
- ))}
- </LeaderboardList>
- )}
- </LeaderboardCard>
+ <ScoreLeaderboard chartId={chartId} />
</PreGameRight>
</PreGameGrid>
</PreGameCard>
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({
<StatLabel>Missed Chars</StatLabel>
</StatBlock>
</StatsGrid>
+ {isUploadingScore ? (
+ <PreviewHint>Uploading score...</PreviewHint>
+ ) : isUploadFlowComplete ? (
+ <ScoreLeaderboard chartId={chartId} />
+ ) : null}
<ActionRow>
<PlayAgainBtn onClick={onPlayAgain}>Play Again</PlayAgainBtn>
<HomeBtn onClick={() => router.push("/")}>Home</HomeBtn>
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<string, unknown> & {
+ 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<string, unknown>;
+ 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<LeaderboardEntry[]>([]);
+ 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<ScoreRecord>({ sort: "-score", expand: "user" });
+
+ if (cancelled) return;
+
+ const bestScores = new Map<string, LeaderboardEntry>();
+
+ 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 (
+ <LeaderboardCard>
+ <LeaderboardHeader>
+ <LeaderboardTitle>Leaderboard</LeaderboardTitle>
+ <LeaderboardCount>
+ {leaderboardEntries.length ? `Top ${leaderboardEntries.length}` : "Top 0"}
+ </LeaderboardCount>
+ </LeaderboardHeader>
+
+ {!chartId ? (
+ <PreviewHint>Leaderboard unavailable for this chart.</PreviewHint>
+ ) : loadingLeaderboard ? (
+ <PreviewHint>Loading leaderboard...</PreviewHint>
+ ) : leaderboardEntries.length === 0 ? (
+ <PreviewHint>No scores yet. Be the first!</PreviewHint>
+ ) : (
+ <LeaderboardList>
+ {leaderboardEntries.map((entry, index) => (
+ <LeaderboardRow key={entry.id}>
+ <LeaderboardRank>#{index + 1}</LeaderboardRank>
+ <LeaderboardEntryBody>
+ <LeaderboardEntryTop>
+ <LeaderboardName>{entry.name}</LeaderboardName>
+ <LeaderboardScore>{entry.score.toLocaleString()}</LeaderboardScore>
+ </LeaderboardEntryTop>
+ <LeaderboardMetrics>
+ <LeaderboardMetric>Acc: {entry.accuracy}%</LeaderboardMetric>
+ <LeaderboardMetric>Combo: x{entry.combo}</LeaderboardMetric>
+ <LeaderboardMetric>WPM: {entry.wpm}</LeaderboardMetric>
+ </LeaderboardMetrics>
+ </LeaderboardEntryBody>
+ </LeaderboardRow>
+ ))}
+ </LeaderboardList>
+ )}
+ </LeaderboardCard>
+ );
+}
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`
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage