aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-11 23:14:04 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-11 23:14:04 -0700
commite156d7200a136178f73c7590877087d8e06e6382 (patch)
treeca0e5849cd8164b4994462e83ee81b26f181f2fd
parente7a4a7410ca9f512a7dec4ba92d4b592d0b43512 (diff)
implement leaderboard
-rw-r--r--pb_migrations/1781244232_updated_scores.js20
-rw-r--r--pb_migrations/1781244260_updated_scores.js22
-rw-r--r--src/app/game/[slug]/components/PreGameView.tsx257
-rw-r--r--src/app/game/[slug]/components/ResultsView.tsx54
-rw-r--r--src/app/game/[slug]/hooks/useGameEngine.ts5
-rw-r--r--src/app/game/[slug]/page.styles.ts96
-rw-r--r--src/app/game/[slug]/page.tsx4
7 files changed, 402 insertions, 56 deletions
diff --git a/pb_migrations/1781244232_updated_scores.js b/pb_migrations/1781244232_updated_scores.js
new file mode 100644
index 0000000..5bb7eec
--- /dev/null
+++ b/pb_migrations/1781244232_updated_scores.js
@@ -0,0 +1,20 @@
+/// <reference path="../pb_data/types.d.ts" />
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_809716240")
+
+ // update collection data
+ unmarshal({
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_809716240")
+
+ // update collection data
+ unmarshal({
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1781244260_updated_scores.js b/pb_migrations/1781244260_updated_scores.js
new file mode 100644
index 0000000..e3a4533
--- /dev/null
+++ b/pb_migrations/1781244260_updated_scores.js
@@ -0,0 +1,22 @@
+/// <reference path="../pb_data/types.d.ts" />
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_809716240")
+
+ // update collection data
+ unmarshal({
+ "listRule": "",
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_809716240")
+
+ // update collection data
+ unmarshal({
+ "listRule": null,
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/src/app/game/[slug]/components/PreGameView.tsx b/src/app/game/[slug]/components/PreGameView.tsx
index a92b1cb..77af6ea 100644
--- a/src/app/game/[slug]/components/PreGameView.tsx
+++ b/src/app/game/[slug]/components/PreGameView.tsx
@@ -1,8 +1,13 @@
"use client";
+import { useEffect, useState } from "react";
+import pb from "../../../lib/pocketbase";
import {
StartOverlay,
- StartCard,
+ PreGameCard,
+ PreGameGrid,
+ PreGameLeft,
+ PreGameRight,
SongTitleText,
SongArtistText,
StartBtn,
@@ -13,6 +18,15 @@ import {
PreviewWrap,
PreviewBtn,
PreviewHint,
+ LeaderboardCard,
+ LeaderboardHeader,
+ LeaderboardTitle,
+ LeaderboardCount,
+ LeaderboardList,
+ LeaderboardRow,
+ LeaderboardRank,
+ LeaderboardName,
+ LeaderboardScore,
} from "../page.styles";
interface PreGameViewProps {
@@ -20,6 +34,7 @@ interface PreGameViewProps {
loadingLrc: boolean;
songTitle: string;
songArtist: string;
+ chartId: string | null;
audioUrl: string;
isVideo: boolean;
audioVolume: number;
@@ -31,11 +46,48 @@ 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,
songTitle,
songArtist,
+ chartId,
audioUrl,
isVideo,
audioVolume,
@@ -46,66 +98,161 @@ 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>
- <StartCard>
- {!isReady ? (
- <>
- <SongTitleText>Loading...</SongTitleText>
- <SongArtistText>Please wait while we load the chart</SongArtistText>
- </>
- ) : (
- <>
- <SongTitleText>{loadingLrc ? "Loading..." : songTitle}</SongTitleText>
- <SongArtistText>{songArtist}</SongArtistText>
- </>
- )}
+ <PreGameCard>
+ <PreGameGrid>
+ <PreGameLeft>
+ {!isReady ? (
+ <>
+ <SongTitleText>Loading...</SongTitleText>
+ <SongArtistText>Please wait while we load the chart</SongArtistText>
+ </>
+ ) : (
+ <>
+ <SongTitleText>{loadingLrc ? "Loading..." : songTitle}</SongTitleText>
+ <SongArtistText>{songArtist}</SongArtistText>
+ </>
+ )}
+
+ <StartBtn onClick={onStart} disabled={!isReady} suppressHydrationWarning>
+ {loadingLrc ? "Loading song..." : "▶ Start Game"}
+ </StartBtn>
+
+ <OpacityControl>
+ <OpacityLabel>
+ Volume
+ <OpacityValue>{audioVolume}%</OpacityValue>
+ </OpacityLabel>
+ <OpacitySlider
+ type="range"
+ min="0"
+ max="100"
+ value={audioVolume}
+ onChange={(e) => setAudioVolume(Number(e.target.value))}
+ />
+ </OpacityControl>
- <StartBtn onClick={onStart} disabled={!isReady} suppressHydrationWarning>
- {loadingLrc ? "Loading song..." : "▶ Start Game"}
- </StartBtn>
+ {isVideo && (
+ <OpacityControl>
+ <OpacityLabel>
+ Background opacity
+ <OpacityValue>{backgroundOpacity}%</OpacityValue>
+ </OpacityLabel>
+ <OpacitySlider
+ type="range"
+ min="0"
+ max="100"
+ value={backgroundOpacity}
+ onChange={(e) => setBackgroundOpacity(Number(e.target.value))}
+ />
+ </OpacityControl>
+ )}
- <OpacityControl>
- <OpacityLabel>
- Volume
- <OpacityValue>{audioVolume}%</OpacityValue>
- </OpacityLabel>
- <OpacitySlider
- type="range"
- min="0"
- max="100"
- value={audioVolume}
- onChange={(e) => setAudioVolume(Number(e.target.value))}
- />
- </OpacityControl>
+ <PreviewWrap>
+ <PreviewBtn onClick={onPreviewToggle} disabled={!audioUrl} suppressHydrationWarning>
+ {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"}
+ </PreviewBtn>
+ <PreviewHint>
+ {audioUrl
+ ? "Use preview to test your volume before starting."
+ : "Load a chart to enable audio preview."}
+ </PreviewHint>
+ </PreviewWrap>
+ </PreGameLeft>
- {isVideo && (
- <OpacityControl>
- <OpacityLabel>
- Background opacity
- <OpacityValue>{backgroundOpacity}%</OpacityValue>
- </OpacityLabel>
- <OpacitySlider
- type="range"
- min="0"
- max="100"
- value={backgroundOpacity}
- onChange={(e) => setBackgroundOpacity(Number(e.target.value))}
- />
- </OpacityControl>
- )}
+ <PreGameRight>
+ <LeaderboardCard>
+ <LeaderboardHeader>
+ <LeaderboardTitle>Leaderboard</LeaderboardTitle>
+ <LeaderboardCount>
+ {leaderboardEntries.length
+ ? `Top ${leaderboardEntries.length}`
+ : "Top 0"}
+ </LeaderboardCount>
+ </LeaderboardHeader>
- <PreviewWrap>
- <PreviewBtn onClick={onPreviewToggle} disabled={!audioUrl} suppressHydrationWarning>
- {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"}
- </PreviewBtn>
- <PreviewHint>
- {audioUrl
- ? "Use preview to test your volume before starting."
- : "Load a chart to enable audio preview."}
- </PreviewHint>
- </PreviewWrap>
- </StartCard>
+ {!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>
+ </PreGameRight>
+ </PreGameGrid>
+ </PreGameCard>
</StartOverlay>
);
}
diff --git a/src/app/game/[slug]/components/ResultsView.tsx b/src/app/game/[slug]/components/ResultsView.tsx
index 48b41bb..df9d72c 100644
--- a/src/app/game/[slug]/components/ResultsView.tsx
+++ b/src/app/game/[slug]/components/ResultsView.tsx
@@ -1,6 +1,8 @@
"use client";
+import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
+import { toast } from "react-toastify";
import {
ResultsOverlay,
ResultsCard,
@@ -15,12 +17,15 @@ import {
HomeBtn,
} from "../page.styles";
import { GState } from "../game.stat";
+import { useAuth } from "../../../context/auth";
+import pb from "../../../lib/pocketbase";
interface ResultsViewProps {
g: GState;
accuracy: number;
wpm: number;
songTitle: string;
+ chartId: string | null;
onPlayAgain: () => void;
}
@@ -29,9 +34,58 @@ export default function ResultsView({
accuracy,
wpm,
songTitle,
+ chartId,
onPlayAgain,
}: ResultsViewProps) {
const router = useRouter();
+ const { user, loading } = useAuth();
+ const hasTriedAutoUploadRef = useRef(false);
+
+ useEffect(() => {
+ if (hasTriedAutoUploadRef.current) return;
+ if (loading) return;
+
+ hasTriedAutoUploadRef.current = true;
+
+ if (!user || !chartId) return;
+
+ const dedupeKey = `scores:auto:${chartId}:${user.id}:${g.score}:${wpm}:${g.maxCombo}:${g.totalMiss}:${accuracy}`;
+
+ if (sessionStorage.getItem(dedupeKey) === "1") return;
+
+ const uploadScore = async () => {
+ try {
+ try {
+ await pb.collection("scores").create({
+ accuracy,
+ combo: g.maxCombo,
+ wpm,
+ miss: g.totalMiss,
+ score: g.score,
+ user: user.id,
+ chart: chartId,
+ });
+ } catch {
+ await pb.collection("scores").create({
+ accuracy,
+ combo: g.maxCombo,
+ wpm,
+ miss: g.totalMiss,
+ score: g.score,
+ user: [user.id],
+ chart: [chartId],
+ });
+ }
+
+ sessionStorage.setItem(dedupeKey, "1");
+ toast.success("Score uploaded.", { theme: "dark" });
+ } catch {
+ toast.error("Failed to upload score. Please try again.", { theme: "dark" });
+ }
+ };
+
+ void uploadScore();
+ }, [accuracy, chartId, g.maxCombo, g.score, g.totalMiss, loading, user, wpm]);
return (
<ResultsOverlay>
diff --git a/src/app/game/[slug]/hooks/useGameEngine.ts b/src/app/game/[slug]/hooks/useGameEngine.ts
index 42fc2c1..da3240e 100644
--- a/src/app/game/[slug]/hooks/useGameEngine.ts
+++ b/src/app/game/[slug]/hooks/useGameEngine.ts
@@ -57,6 +57,7 @@ export interface GameEngineResult {
audioUrl: string;
songTitle: string;
songArtist: string;
+ chartId: string | null;
offset: number;
loadingLrc: boolean;
@@ -127,6 +128,7 @@ export function useGameEngine(): GameEngineResult {
const [audioUrl, setAudioUrl] = useState("");
const [songTitle, setSongTitle] = useState("Unknown Title");
const [songArtist, setSongArtist] = useState("Unknown Artist");
+ const [chartId, setChartId] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const [loadingLrc, setLoadingLrc] = useState(false);
@@ -412,6 +414,7 @@ export function useGameEngine(): GameEngineResult {
pb.collection("charts")
.getOne(slug)
.then((record) => {
+ setChartId(record.id);
loadData({
media: (record as Record<string, unknown>).media,
lrc: (record as Record<string, unknown>).lrc,
@@ -421,6 +424,7 @@ export function useGameEngine(): GameEngineResult {
});
})
.catch(() => {
+ setChartId(null);
try {
const json = atob(slug);
const data = JSON.parse(json) as Record<string, unknown>;
@@ -586,6 +590,7 @@ export function useGameEngine(): GameEngineResult {
audioUrl,
songTitle,
songArtist,
+ chartId,
offset,
loadingLrc,
diff --git a/src/app/game/[slug]/page.styles.ts b/src/app/game/[slug]/page.styles.ts
index 1b5880b..38411ea 100644
--- a/src/app/game/[slug]/page.styles.ts
+++ b/src/app/game/[slug]/page.styles.ts
@@ -395,6 +395,102 @@ export const StartCard = styled.div`
text-align: center;
`;
+export const PreGameCard = styled(StartCard)`
+ max-width: 980px;
+ align-items: stretch;
+ text-align: left;
+ gap: 24px;
+`;
+
+export const PreGameGrid = styled.div`
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
+ gap: 24px;
+ width: 100%;
+
+ @media (max-width: 920px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+export const PreGameLeft = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+export const PreGameRight = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`;
+
+export const LeaderboardCard = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+`;
+
+export const LeaderboardHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+export const LeaderboardTitle = styled.div`
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ letter-spacing: 1px;
+ text-transform: uppercase;
+`;
+
+export const LeaderboardCount = styled.span`
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.6);
+ font-variant-numeric: tabular-nums;
+`;
+
+export const LeaderboardList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+export const LeaderboardRow = styled.div`
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+`;
+
+export const LeaderboardRank = styled.span`
+ color: rgba(255, 255, 255, 0.6);
+ font-variant-numeric: tabular-nums;
+ font-size: 13px;
+`;
+
+export const LeaderboardName = styled.span`
+ color: rgba(255, 255, 255, 0.86);
+ font-size: 13px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+export const LeaderboardScore = styled.span`
+ color: #ffffff;
+ font-weight: 700;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+`;
+
export const OpacityControl = styled.div`
width: 100%;
display: flex;
diff --git a/src/app/game/[slug]/page.tsx b/src/app/game/[slug]/page.tsx
index 57ae6b2..312ee70 100644
--- a/src/app/game/[slug]/page.tsx
+++ b/src/app/game/[slug]/page.tsx
@@ -17,7 +17,7 @@ function GameInner() {
// refs
audioRef, videoRef, charRowRef, charRefs,
// song
- audioUrl, songTitle, songArtist, isVideo, isReady, loadingLrc,
+ audioUrl, songTitle, songArtist, chartId, isVideo, isReady, loadingLrc,
// phase
phase, countdown,
// game state
@@ -76,6 +76,7 @@ function GameInner() {
loadingLrc={loadingLrc}
songTitle={songTitle}
songArtist={songArtist}
+ chartId={chartId}
audioUrl={audioUrl}
isVideo={isVideo}
audioVolume={audioVolume}
@@ -120,6 +121,7 @@ function GameInner() {
accuracy={accuracy}
wpm={wpm}
songTitle={songTitle}
+ chartId={chartId}
onPlayAgain={handleRestart}
/>
)}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage