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

View File

@@ -6,8 +6,12 @@ import {
MatchData,
MatchmakerMatched,
} from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @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";
@@ -34,6 +38,7 @@ export interface NakamaContextType {
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: any) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
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[]> {
if (!session) {
console.warn("[Nakama] listOpenMatches called before login");
@@ -176,6 +191,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
joinMatch,
sendMatchData,
onMatchData,
getLeaderboardTop,
listOpenMatches,
}}
>

View File

@@ -1,14 +1,16 @@
body {
font-family: sans-serif;
background: #f4f4f4;
background: #eaeef3;
display: flex;
justify-content: center;
padding-top: 50px;
margin: 0;
color: #222;
}
.game-container {
text-align: center;
padding: 20px;
}
.board {
@@ -22,32 +24,111 @@ body {
.square {
background: white;
border: 2px solid #333;
font-size: 2rem;
border-radius: 10px;
border: 2px solid #444;
font-size: 2.2rem;
font-weight: bold;
cursor: pointer;
height: 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 {
background: #eee;
background: #f1f1f1;
transform: scale(1.04);
}
.status {
font-size: 1.4rem;
margin-bottom: 10px;
font-size: 1.6rem;
margin-bottom: 15px;
font-weight: bold;
color: #222;
}
.reset-btn {
padding: 10px 20px;
background: #333;
padding: 12px 26px;
background: #3558d8;
color: white;
border: none;
cursor: pointer;
font-size: 1rem;
border-radius: 8px;
transition: 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.25);
}
.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;
}