feat(tictactoe): integrate leaderboard UI, provider API hook, and new styles

- Add *_BKP* ignore rule to .gitignore
- Insert Leaderboard component into TicTacToe screen
- Add getLeaderboardTop() API method to NakamaProvider and expose via context
- Add full leaderboard polling logic (interval-based) in new Leaderboard.tsx
- Style leaderboard in styles.css (rows, rank, name, score, empty state, card UI)
- Modernize TicTacToe UI styles (board, squares, buttons, layout)
- Replace matchmaking view with leaderboard integration
- Import ApiLeaderboardRecordList and ApiMatch types cleanly
This commit is contained in:
2025-11-28 19:43:59 +05:30
parent 33d917c8f2
commit 5fb3ad4205
4 changed files with 178 additions and 11 deletions

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useRef, useEffect, useState } from "react";
import {
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import { useNakama } from "./providers/NakamaProvider";
export default function Leaderboard({
intervalMs = 10000,
}: {
intervalMs?: number;
}) {
const {
getLeaderboardTop
} = useNakama()
const [records, setRecords] = useState<ApiLeaderboardRecordList>([]);
const timerRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
async function load() {
try {
const data = await getLeaderboardTop();
if (mounted) setRecords(data);
} catch (err) {
console.error("Leaderboard fetch failed:", err);
}
}
// First load immediately
load();
// Start interval polling
timerRef.current = window.setInterval(load, intervalMs);
return () => {
mounted = false;
if (timerRef.current) clearInterval(timerRef.current);
};
}, [getLeaderboardTop, intervalMs]);
// @ts-ignore
return (
<div className="leaderboard">
<h2 className="leaderboard-title">🏆 Leaderboard Wins</h2>
{records?.records?.length === 0 && (
<div className="leaderboard-empty">No entries yet.</div>
)}
{records?.records?.map((
r: {
owner_id: number,
username: string,
score: number
},
i: number
) => (
<div className="leaderboard-row" key={r.owner_id + i}>
<div className="leaderboard-rank">{i + 1}</div>
<div className="leaderboard-name">{r.username || r.owner_id}</div>
<div className="leaderboard-score">{r.score}</div>
</div>
))}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNakama } from "./providers/NakamaProvider"; import { useNakama } from "./providers/NakamaProvider";
import { Match } from "@heroiclabs/nakama-js";
import Board from "./Board"; import Board from "./Board";
import Leaderboard from "./Leaderboard";
// import { Match } from "@heroiclabs/nakama-js";
// import MatchList from "./MatchList"; // import MatchList from "./MatchList";
export default function TicTacToe() { export default function TicTacToe() {
@@ -143,6 +144,7 @@ export default function TicTacToe() {
{/*/!* List open matches *!/*/} {/*/!* List open matches *!/*/}
{/*<MatchList matches={openMatches} />*/} {/*<MatchList matches={openMatches} />*/}
<Leaderboard/>
</> </>
)} )}

View File

@@ -6,8 +6,12 @@ import {
MatchData, MatchData,
MatchmakerMatched, MatchmakerMatched,
} from "@heroiclabs/nakama-js"; } from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @ts-ignore // @ts-ignore
import { ApiMatch } from "@heroiclabs/nakama-js/dist/api.gen" } from "@heroiclabs/nakama-js/dist/api.gen"
import React, { createContext, useContext, useState } from "react"; import React, { createContext, useContext, useState } from "react";
@@ -34,6 +38,7 @@ export interface NakamaContextType {
sendMatchData(matchId: string, op: number, data: object): void; sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: any) => void): void; onMatchData(cb: (msg: any) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>; listOpenMatches(): Promise<ApiMatch[]>;
} }
@@ -151,6 +156,16 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
}; };
} }
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
if (!session) return [];
return await client.listLeaderboardRecords(
session,
"tictactoe",
[],
10 // top 10
);
}
async function listOpenMatches(): Promise<ApiMatch[]> { async function listOpenMatches(): Promise<ApiMatch[]> {
if (!session) { if (!session) {
console.warn("[Nakama] listOpenMatches called before login"); console.warn("[Nakama] listOpenMatches called before login");
@@ -176,6 +191,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
joinMatch, joinMatch,
sendMatchData, sendMatchData,
onMatchData, onMatchData,
getLeaderboardTop,
listOpenMatches, listOpenMatches,
}} }}
> >

View File

@@ -1,14 +1,16 @@
body { body {
font-family: sans-serif; font-family: sans-serif;
background: #f4f4f4; background: #eaeef3;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-top: 50px; padding-top: 50px;
margin: 0; margin: 0;
color: #222;
} }
.game-container { .game-container {
text-align: center; text-align: center;
padding: 20px;
} }
.board { .board {
@@ -22,32 +24,111 @@ body {
.square { .square {
background: white; background: white;
border: 2px solid #333; border-radius: 10px;
font-size: 2rem; border: 2px solid #444;
font-size: 2.2rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
height: 100px; height: 100px;
width: 100px; width: 100px;
display: flex;
align-items: center;
justify-content: center;
transition: 0.15s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
} }
.square:hover { .square:hover {
background: #eee; background: #f1f1f1;
transform: scale(1.04);
} }
.status { .status {
font-size: 1.4rem; font-size: 1.6rem;
margin-bottom: 10px; margin-bottom: 15px;
font-weight: bold;
color: #222;
} }
.reset-btn { .reset-btn {
padding: 10px 20px; padding: 12px 26px;
background: #333; background: #3558d8;
color: white; color: white;
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
border-radius: 8px;
transition: 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.25);
} }
.reset-btn:hover { .reset-btn:hover {
background: #555; background: #2449c7;
transform: translateY(-2px);
} }
.leaderboard {
width: 300px;
margin: 25px auto;
padding: 15px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
font-family: sans-serif;
}
/* Leaderboard title */
.leaderboard-title {
margin: 0 0 15px 0;
font-size: 1.4rem;
font-weight: 700;
text-align: center;
color: #222;
}
/* Each row */
.leaderboard-row {
display: flex;
align-items: center;
padding: 10px 8px;
background: #f7f9fc;
border-radius: 8px;
margin-bottom: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
}
/* Empty state */
.leaderboard-empty {
padding: 12px 0;
text-align: center;
color: #777;
font-size: 1rem;
}
/* Rank number */
.leaderboard-rank {
width: 28px;
font-size: 1.2rem;
font-weight: 700;
color: #3558d8;
}
/* Username */
.leaderboard-name {
flex: 1;
margin-left: 10px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Score value */
.leaderboard-score {
width: 40px;
text-align: right;
font-weight: 700;
color: #111;
}