feat: add user accounts, authentication, and game history with Prisma 7

- Implement user registration and login with NextAuth v5 (email/password, JWT)
- Add authentication modal in UI with login/register tabs
- Create user profile screen showing game statistics and history
- Integrate Prisma 7 ORM with SQLite database for data persistence
- Store game results (mode, path, clicks, time) in database
- Auto-save completed games only when user is authenticated
- Separate business logic into reusable hooks (useSoloGame, useMultiGame)
- Organize UI into composable screen components (HomeScreen, SoloScreen, ProfileScreen, etc)
- Add session persistence across F5 refresh for solo and multiplayer
- Style auth modal, account button, and profile stats dashboard
This commit is contained in:
jessy-david-dev
2026-04-10 15:43:07 +02:00
parent 6a75e80e6c
commit dc09658fdb
44 changed files with 12371 additions and 91 deletions
+225
View File
@@ -0,0 +1,225 @@
// Route handler pour les actions sur une room specifique
// PATCH /api/rooms/[code] - actions: join, heartbeat, start, navigate, leave, nextRound
import { NextRequest } from "next/server";
import type { Room, Player } from "../route";
// Acces au singleton
declare global {
// eslint-disable-next-line no-var
var __wikirooms: Map<string, Room> | undefined;
}
function getRooms(): Map<string, Room> {
if (!global.__wikirooms) {
global.__wikirooms = new Map();
}
return global.__wikirooms;
}
function generatePlayerId(): string {
return Math.random().toString(36).slice(2, 10);
}
// Timeout joueur inactif : 15s
const PLAYER_TIMEOUT_MS = 15_000;
function prunePlayers(room: Room) {
const now = Date.now();
room.players = room.players.filter(
(p) => now - p.lastSeen < PLAYER_TIMEOUT_MS
);
}
// PATCH /api/rooms/[code]
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
const rooms = getRooms();
const room = rooms.get(code.toUpperCase());
if (!room) {
return Response.json({ error: "Room introuvable" }, { status: 404 });
}
const body = await request.json();
const { action, playerId, playerName, article, startArticle, targetArticle } =
body as {
action: string;
playerId?: string;
playerName?: string;
article?: string;
startArticle?: string;
targetArticle?: string;
};
// Nettoyer les joueurs inactifs avant chaque action
prunePlayers(room);
switch (action) {
// Rejoindre
case "join": {
if (!playerName || typeof playerName !== "string" || playerName.trim() === "") {
return Response.json({ error: "Pseudo invalide" }, { status: 400 });
}
if (room.players.length >= 8) {
return Response.json({ error: "Salle pleine (8 joueurs max)" }, { status: 409 });
}
if (room.phase !== "waiting" && room.phase !== "results") {
return Response.json({ error: "Partie en cours, attends la prochaine manche" }, { status: 409 });
}
const newId = generatePlayerId();
const player: Player = {
id: newId,
name: playerName.trim().slice(0, 20),
score: 0,
currentArticle: "",
hasWon: false,
isHost: false,
lastSeen: Date.now(),
};
room.players.push(player);
return Response.json({ room, playerId: newId });
}
// Heartbeat (polling)
case "heartbeat": {
const player = room.players.find((p) => p.id === playerId);
if (player) {
player.lastSeen = Date.now();
}
return Response.json({ room });
}
// Demarrer la partie
case "start": {
const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) {
return Response.json({ error: "Seul l'hote peut demarrer" }, { status: 403 });
}
if (room.players.length < 1) {
return Response.json({ error: "Pas assez de joueurs" }, { status: 400 });
}
if (!startArticle || !targetArticle) {
return Response.json({ error: "Articles manquants" }, { status: 400 });
}
// Reset scores si c'est la toute premiere manche
if (room.round === 0) {
for (const p of room.players) {
p.score = 0;
}
}
room.round += 1;
room.startArticle = startArticle;
room.targetArticle = targetArticle;
room.roundWinner = null;
room.phase = "countdown";
room.countdownStart = Date.now();
room.roundStart = null;
// Reset etat joueurs pour cette manche
for (const p of room.players) {
p.currentArticle = startArticle;
p.hasWon = false;
}
return Response.json({ room });
}
// Passer en playing (apres countdown)
case "play": {
if (room.phase !== "countdown") {
return Response.json({ room });
}
// On laisse les clients gerer le timing - le 1er qui appelle play apres 3s active
const elapsed = Date.now() - (room.countdownStart ?? 0);
if (elapsed >= 3000) {
room.phase = "playing";
room.roundStart = Date.now();
}
return Response.json({ room });
}
// Navigation vers un article
case "navigate": {
if (room.phase !== "playing") {
return Response.json({ room });
}
const player = room.players.find((p) => p.id === playerId);
if (!player) {
return Response.json({ error: "Joueur inconnu" }, { status: 404 });
}
player.currentArticle = article ?? "";
player.lastSeen = Date.now();
// Verifier si le joueur a atteint la cible
const normalize = (s: string) =>
decodeURIComponent(s).replace(/_/g, " ").toLowerCase().trim();
if (
!player.hasWon &&
normalize(player.currentArticle) === normalize(room.targetArticle)
) {
player.hasWon = true;
// 1er joueur a gagner = +10 points
const alreadyWon = room.players.some(
(p) => p.hasWon && p.id !== player.id
);
if (!alreadyWon) {
player.score += 10;
room.roundWinner = player.id;
room.phase = "results";
}
}
return Response.json({ room });
}
// Manche suivante / rejouer
case "nextRound": {
const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) {
return Response.json({ error: "Seul l'hote peut continuer" }, { status: 403 });
}
room.phase = "waiting";
room.roundWinner = null;
room.countdownStart = null;
room.roundStart = null;
for (const p of room.players) {
p.hasWon = false;
p.currentArticle = "";
}
return Response.json({ room });
}
// Nouvelle partie (reset total)
case "resetGame": {
const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) {
return Response.json({ error: "Seul l'hote peut reinitialiser" }, { status: 403 });
}
room.phase = "waiting";
room.round = 0;
room.roundWinner = null;
room.countdownStart = null;
room.roundStart = null;
for (const p of room.players) {
p.score = 0;
p.hasWon = false;
p.currentArticle = "";
}
return Response.json({ room });
}
default:
return Response.json({ error: "Action inconnue" }, { status: 400 });
}
}
+143
View File
@@ -0,0 +1,143 @@
// Route handler: POST /api/rooms - creer une room
// GET /api/rooms?code=XXXX - recuperer l'etat d'une room
import { NextRequest } from "next/server";
// Types
export type Player = {
id: string;
name: string;
score: number;
currentArticle: string;
hasWon: boolean;
isHost: boolean;
lastSeen: number; // timestamp ms
};
export type Room = {
code: string;
players: Player[];
phase: "waiting" | "countdown" | "playing" | "results";
round: number;
totalRounds: number;
startArticle: string;
targetArticle: string;
roundWinner: string | null; // player id
countdownStart: number | null; // timestamp ms
roundStart: number | null; // timestamp ms
createdAt: number;
};
// Stockage en memoire (singleton Node.js)
declare global {
// eslint-disable-next-line no-var
var __wikirooms: Map<string, Room> | undefined;
}
function getRooms(): Map<string, Room> {
if (!global.__wikirooms) {
global.__wikirooms = new Map();
}
return global.__wikirooms;
}
// Helpers
function generateCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ";
let code = "";
for (let i = 0; i < 4; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
function generatePlayerId(): string {
return Math.random().toString(36).slice(2, 10);
}
// Nettoie les rooms inactives depuis plus de 2h
function pruneOldRooms(rooms: Map<string, Room>) {
const now = Date.now();
for (const [code, room] of rooms) {
if (now - room.createdAt > 2 * 60 * 60 * 1000) {
rooms.delete(code);
}
}
}
// Handlers
// POST /api/rooms
// Body: { playerName: string }
// Response: { room: Room, playerId: string }
export async function POST(request: NextRequest) {
const body = await request.json();
const { playerName } = body as { playerName: string };
if (!playerName || typeof playerName !== "string" || playerName.trim() === "") {
return Response.json({ error: "Pseudo invalide" }, { status: 400 });
}
const rooms = getRooms();
pruneOldRooms(rooms);
// Generer un code unique
let code = generateCode();
let attempts = 0;
while (rooms.has(code) && attempts < 20) {
code = generateCode();
attempts++;
}
const playerId = generatePlayerId();
const room: Room = {
code,
players: [
{
id: playerId,
name: playerName.trim().slice(0, 20),
score: 0,
currentArticle: "",
hasWon: false,
isHost: true,
lastSeen: Date.now(),
},
],
phase: "waiting",
round: 0,
totalRounds: 3,
startArticle: "",
targetArticle: "",
roundWinner: null,
countdownStart: null,
roundStart: null,
createdAt: Date.now(),
};
rooms.set(code, room);
return Response.json({ room, playerId });
}
// GET /api/rooms?code=XXXX
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
if (!code) {
return Response.json({ error: "Code manquant" }, { status: 400 });
}
const rooms = getRooms();
const room = rooms.get(code.toUpperCase());
if (!room) {
return Response.json({ error: "Room introuvable" }, { status: 404 });
}
return Response.json({ room });
}