From e156d7200a136178f73c7590877087d8e06e6382 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Thu, 11 Jun 2026 23:14:04 -0700 Subject: implement leaderboard --- pb_migrations/1781244232_updated_scores.js | 20 ++ pb_migrations/1781244260_updated_scores.js | 22 ++ src/app/game/[slug]/components/PreGameView.tsx | 265 +++++++++++++++++++------ src/app/game/[slug]/components/ResultsView.tsx | 54 +++++ src/app/game/[slug]/hooks/useGameEngine.ts | 5 + src/app/game/[slug]/page.styles.ts | 96 +++++++++ src/app/game/[slug]/page.tsx | 4 +- 7 files changed, 406 insertions(+), 60 deletions(-) create mode 100644 pb_migrations/1781244232_updated_scores.js create mode 100644 pb_migrations/1781244260_updated_scores.js 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 @@ +/// +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 @@ +/// +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 & { + 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, songTitle, songArtist, + chartId, audioUrl, isVideo, audioVolume, @@ -46,66 +98,161 @@ 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 ( - - {!isReady ? ( - <> - Loading... - Please wait while we load the chart - - ) : ( - <> - {loadingLrc ? "Loading..." : songTitle} - {songArtist} - - )} - - - {loadingLrc ? "Loading song..." : "▶ Start Game"} - - - - - Volume - {audioVolume}% - - setAudioVolume(Number(e.target.value))} - /> - - - {isVideo && ( - - - Background opacity - {backgroundOpacity}% - - setBackgroundOpacity(Number(e.target.value))} - /> - - )} - - - - {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} - - - {audioUrl - ? "Use preview to test your volume before starting." - : "Load a chart to enable audio preview."} - - - + + + + {!isReady ? ( + <> + Loading... + Please wait while we load the chart + + ) : ( + <> + {loadingLrc ? "Loading..." : songTitle} + {songArtist} + + )} + + + {loadingLrc ? "Loading song..." : "▶ Start Game"} + + + + + Volume + {audioVolume}% + + setAudioVolume(Number(e.target.value))} + /> + + + {isVideo && ( + + + Background opacity + {backgroundOpacity}% + + setBackgroundOpacity(Number(e.target.value))} + /> + + )} + + + + {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} + + + {audioUrl + ? "Use preview to test your volume before starting." + : "Load a chart to enable audio preview."} + + + + + + + + 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 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 ( 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(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).media, lrc: (record as Record).lrc, @@ -421,6 +424,7 @@ export function useGameEngine(): GameEngineResult { }); }) .catch(() => { + setChartId(null); try { const json = atob(slug); const data = JSON.parse(json) as Record; @@ -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} /> )} -- cgit v1.2.3