diff --git a/.gitignore b/.gitignore index d748ac4..98e8a28 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.claude*.db +.claude/ +*.db diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..b6e5479 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: Request, { params }: Params) { + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + createdAt: true, + games: { + select: { mode: true, won: true, clicks: true, timeSeconds: true }, + }, + }, + }); + + if (!user) { + return NextResponse.json({ error: "Joueur introuvable" }, { status: 404 }); + } + + const all = user.games; + const won = all.filter((g) => g.won); + const solo = all.filter((g) => g.mode === "solo"); + const multi = all.filter((g) => g.mode === "multi"); + const daily = all.filter((g) => g.mode === "daily"); + const blitz = all.filter((g) => g.mode === "blitz"); + + const avgClicks = won.length + ? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length) + : null; + const bestClicks = won.length ? Math.min(...won.map((g) => g.clicks)) : null; + const bestTime = won.length ? Math.min(...won.map((g) => g.timeSeconds)) : null; + const avgTime = won.length + ? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length + : null; + + return NextResponse.json({ + id: user.id, + name: user.name, + createdAt: user.createdAt, + stats: { + total: all.length, + wins: won.length, + winRate: all.length ? Math.round((won.length / all.length) * 100) : 0, + avgClicks, + bestClicks, + bestTime, + avgTime, + solo: { games: solo.length, wins: solo.filter((g) => g.won).length }, + multi: { games: multi.length, wins: multi.filter((g) => g.won).length }, + daily: { games: daily.length, wins: daily.filter((g) => g.won).length }, + blitz: { games: blitz.length, wins: blitz.filter((g) => g.won).length }, + }, + }); +} diff --git a/app/components/LeaderboardScreen.tsx b/app/components/LeaderboardScreen.tsx index 1e3a4cb..dff905f 100644 --- a/app/components/LeaderboardScreen.tsx +++ b/app/components/LeaderboardScreen.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { PublicProfileScreen } from "./PublicProfileScreen"; type LeaderboardRow = { id: string; @@ -29,6 +30,7 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [mode, setMode] = useState("all"); + const [viewingUserId, setViewingUserId] = useState(null); useEffect(() => { fetch("/api/leaderboard") @@ -36,6 +38,10 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) { .then((data) => { setRows(data); setLoading(false); }); }, []); + if (viewingUserId) { + return setViewingUserId(null)} />; + } + const sorted = [...rows] .filter((r) => mode === "solo" ? r.soloGames > 0 : mode === "multi" ? r.multiGames > 0 : true) .sort((a, b) => { @@ -81,7 +87,7 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) { const wins = mode === "solo" ? row.soloWins : mode === "multi" ? row.multiWins : row.wins; return ( -
+
setViewingUserId(row.id)} className={`flex items-center gap-2.5 px-3.5 py-3 rounded-xl border cursor-pointer hover:brightness-110 transition-all ${i < 3 ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}> {MEDALS[i] ?? `#${i + 1}`}
{row.name} diff --git a/app/components/PublicProfileScreen.tsx b/app/components/PublicProfileScreen.tsx new file mode 100644 index 0000000..2b80a93 --- /dev/null +++ b/app/components/PublicProfileScreen.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type PublicProfile = { + id: string; + name: string; + createdAt: string; + stats: { + total: number; + wins: number; + winRate: number; + avgClicks: number | null; + bestClicks: number | null; + bestTime: number | null; + avgTime: number | null; + solo: { games: number; wins: number }; + multi: { games: number; wins: number }; + daily: { games: number; wins: number }; + blitz: { games: number; wins: number }; + }; +}; + +function fmt(s: number): string { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${String(sec).padStart(2, "0")}`; +} + +const MODE_LABELS: Record = { + solo: "Solo", + multi: "Multijoueur", + daily: "Défi du jour", + blitz: "Blitz", +}; + +export function PublicProfileScreen({ + userId, + onBack, +}: { + userId: string; + onBack: () => void; +}) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + fetch(`/api/users/${userId}`) + .then((r) => { + if (r.status === 404) { setNotFound(true); return null; } + return r.json(); + }) + .then((d) => { if (d) setProfile(d); }) + .finally(() => setLoading(false)); + }, [userId]); + + const statCard = "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1"; + const btnGhost = "min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors"; + + return ( +
+
+ + {profile && ( +
+ + {profile.name[0].toUpperCase()} + +
+

{profile.name}

+

+ Membre depuis {new Date(profile.createdAt).toLocaleDateString("fr-FR", { month: "long", year: "numeric" })} +

+
+
+ )} +
+ + {loading &&
Chargement...
} + {notFound &&

Joueur introuvable.

} + + {profile && ( +
+ {/* Stats principales */} +
+
+ {profile.stats.total} + Parties +
+
+ {profile.stats.wins} + Victoires +
+
+ {profile.stats.winRate}% + Win rate +
+
+ + {profile.stats.avgClicks ?? "-"} + + Clics moy. +
+
+ + {profile.stats.bestClicks ?? "-"} + + Meilleur clics +
+
+ + {profile.stats.bestTime ? fmt(profile.stats.bestTime) : "-"} + + Meilleur temps +
+
+ + {/* Stats par mode */} +
+

Par mode

+ {(["solo", "multi", "daily", "blitz"] as const).map((m) => { + const s = profile.stats[m]; + if (s.games === 0) return null; + return ( +
+ {MODE_LABELS[m]} +
+ {s.wins} victoires + {s.games} parties +
+
+ ); + })} +
+
+ )} +
+ ); +}