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:
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "../../../../auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../../auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
// POST /api/games — sauvegarder une partie
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non connecté" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { mode, startArticle, targetArticle, path, clicks, timeSeconds, won } =
|
||||
await req.json() as {
|
||||
mode: string;
|
||||
startArticle: string;
|
||||
targetArticle: string;
|
||||
path: string[];
|
||||
clicks: number;
|
||||
timeSeconds: number;
|
||||
won: boolean;
|
||||
};
|
||||
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
mode,
|
||||
startArticle,
|
||||
targetArticle,
|
||||
path: JSON.stringify(path),
|
||||
clicks,
|
||||
timeSeconds,
|
||||
won,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: game.id });
|
||||
}
|
||||
|
||||
// GET /api/games — historique de l'utilisateur connecté
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non connecté" }, { status: 401 });
|
||||
}
|
||||
|
||||
const games = await prisma.game.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { playedAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
games.map((g) => ({
|
||||
...g,
|
||||
path: JSON.parse(g.path) as string[],
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { name, email, password } = await req.json() as { name?: string; email?: string; password?: string };
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !password || password.length < 6) {
|
||||
return NextResponse.json({ error: "Champs invalides (mot de passe min. 6 caractères)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Cet email est déjà utilisé" }, { status: 409 });
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: { name: name.trim(), email: email.toLowerCase(), password: hashed },
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: user.id, name: user.name, email: user.email }, { status: 201 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user