diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..31a5c89 --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/prisma"; + +export async function GET() { + const users = await prisma.user.findMany({ + include: { games: true }, + }); + + const rows = users + .map((u) => { + const all = u.games; + const solo = all.filter((g) => g.mode === "solo"); + const multi = all.filter((g) => g.mode === "multi"); + const wins = all.filter((g) => g.won); + const wonGames = all.filter((g) => g.won && g.clicks > 0); + const avgClicks = wonGames.length + ? Math.round(wonGames.reduce((s, g) => s + g.clicks, 0) / wonGames.length) + : null; + const bestTime = wins.length + ? Math.min(...wins.map((g) => g.timeSeconds)) + : null; + + return { + id: u.id, + name: u.name, + totalGames: all.length, + wins: wins.length, + soloGames: solo.length, + soloWins: solo.filter((g) => g.won).length, + multiGames: multi.length, + multiWins: multi.filter((g) => g.won).length, + avgClicks, + bestTime, + }; + }) + .filter((r) => r.totalGames > 0) + .sort((a, b) => b.wins - a.wins || b.totalGames - a.totalGames); + + return NextResponse.json(rows); +} diff --git a/app/components/HomeScreen.tsx b/app/components/HomeScreen.tsx index c035543..7122d79 100644 --- a/app/components/HomeScreen.tsx +++ b/app/components/HomeScreen.tsx @@ -17,16 +17,20 @@ type HomeScreenProps = { session: Session | null; onShowAuth: () => void; onShowProfile: () => void; + onShowLeaderboard: () => void; }; export function HomeScreen({ playerName, setPlayerName, joinCode, setJoinCode, error, setError, loading, onCreateRoom, onJoinRoom, onSolo, - session, onShowAuth, onShowProfile, + session, onShowAuth, onShowProfile, onShowLeaderboard, }: HomeScreenProps) { return (
+ {session?.user ? ( +

Classement

+
+ +
+ {(["all", "solo", "multi"] as Mode[]).map((m) => ( + + ))} +
+ + {loading ? ( +
+ ) : sorted.length === 0 ? ( +

Aucune partie jouée pour le moment.

+ ) : ( +
+ {sorted.map((row, i) => { + const games = mode === "solo" ? row.soloGames : mode === "multi" ? row.multiGames : row.totalGames; + const wins = mode === "solo" ? row.soloWins : mode === "multi" ? row.multiWins : row.wins; + + return ( +
+ {MEDALS[i] ?? `#${i + 1}`} + {row.name} +
+ {wins}victoires + {games}parties + {row.avgClicks != null && ( + {row.avgClicks}moy. clics + )} + {row.bestTime != null && ( + {fmt(row.bestTime)}meilleur temps + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/components/ScreenRouter.tsx b/app/components/ScreenRouter.tsx index 8f17b29..31003e6 100644 --- a/app/components/ScreenRouter.tsx +++ b/app/components/ScreenRouter.tsx @@ -13,6 +13,7 @@ import { SoloScreen } from "./SoloScreen"; import { GameScreen } from "./GameScreen"; import { AuthModal } from "./AuthModal"; import { ProfileScreen } from "./ProfileScreen"; +import { LeaderboardScreen } from "./LeaderboardScreen"; type Props = { screen: Screen; @@ -41,6 +42,10 @@ export function ScreenRouter({ setScreen("home")} /> ); + if (screen === "leaderboard") return ( + setScreen("home")} /> + ); + if (screen === "home") return ( <> setShowAuth(true)} onShowProfile={() => setScreen("profile")} + onShowLeaderboard={() => setScreen("leaderboard")} /> {showAuth && setShowAuth(false)} onSuccess={() => setShowAuth(false)} />} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..8476065 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index e12e2f3..bb42f73 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1323,6 +1323,9 @@ body { position: absolute; top: 16px; right: 16px; + display: flex; + align-items: center; + gap: 8px; } .btn-account { @@ -1545,3 +1548,128 @@ body { } .game-path-sep { margin: 0 2px; } .game-path-end { color: var(--green); font-weight: 600; } + +/* ================================ + Leaderboard + ================================ */ + +.leaderboard-screen { + max-width: 640px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.leaderboard-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.leaderboard-title { + font-size: 24px; + font-weight: 800; +} + +.leaderboard-tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.leaderboard-tab { + flex: 1; + padding: 8px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background var(--transition), color var(--transition); +} + +.leaderboard-tab.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.leaderboard-loading { + display: flex; + justify-content: center; + padding: 48px; +} + +.leaderboard-empty { + text-align: center; + color: var(--text-muted); + padding: 48px 0; +} + +.leaderboard-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.leaderboard-row { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-2); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.leaderboard-row.top { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); +} + +.lb-rank { + font-size: 22px; + width: 32px; + text-align: center; + flex-shrink: 0; +} + +.lb-name { + font-weight: 700; + font-size: 15px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lb-stats { + display: flex; + gap: 16px; + flex-shrink: 0; +} + +.lb-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.lb-stat-val { + font-size: 15px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.lb-stat-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} diff --git a/lib/types.ts b/lib/types.ts index 8c61e43..9cca4a5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,4 @@ -export type Screen = "home" | "lobby" | "game" | "solo" | "profile"; +export type Screen = "home" | "lobby" | "game" | "solo" | "profile" | "leaderboard"; export type WikiArticle = { title: string;