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
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "../../../../auth";
export const { GET, POST } = handlers;
+58
View File
@@ -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[],
}))
);
}
+23
View File
@@ -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 });
}
+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 });
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef } from "react";
const FORBIDDEN_NAMESPACES = [
"Fichier:", "File:", "Wikipedia:", "Aide:", "Help:", "Categorie:", "Category:",
"Discussion:", "Talk:", "Utilisateur:", "User:", "Special:", "Sp\u00e9cial:",
"Portail:", "Portal:", "Mod\u00e8le:", "Template:", "Projet:", "WP:",
];
const REMOVED_SECTION_IDS = [
"Liens_externes", "R\u00e9f\u00e9rences", "Notes", "Bibliographie",
"Voir_aussi", "Notes_et_r\u00e9f\u00e9rences", "Sources",
"Annexes", "Articles_connexes",
];
function cleanWikiHtml(container: HTMLElement): void {
container.querySelectorAll(".mw-editsection").forEach((el) => el.remove());
container.querySelectorAll(
".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference"
).forEach((el) => el.remove());
container.querySelectorAll(
".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail"
).forEach((el) => el.remove());
container.querySelectorAll(
".ambox, .tmbox, .cmbox, .ombox, .fmbox, .hatnote, .bandeau-container, .bandeau"
).forEach((el) => el.remove());
container.querySelectorAll(".audio, .audiolink, audio, video").forEach((el) => el.remove());
container.querySelectorAll(".gallery").forEach((el) => el.remove());
container.querySelectorAll("#toc, .toc").forEach((el) => el.remove());
container.querySelectorAll("div, nav").forEach((el) => {
const links = el.querySelectorAll("a");
if (links.length > 3) {
const anchorOnly = Array.from(links).every((a) => {
const href = a.getAttribute("href") ?? "";
return href.startsWith("#") || href.includes("#");
});
if (anchorOnly) el.remove();
}
});
container.querySelectorAll("h2, h3").forEach((heading) => {
const span = heading.querySelector("span[id]");
if (!span) return;
const id = span.getAttribute("id") ?? "";
if (REMOVED_SECTION_IDS.some((s) => id === s || id.startsWith(s + "_"))) {
let sibling: Element | null = heading;
while (sibling) {
const next: Element | null = sibling.nextElementSibling;
sibling.remove();
sibling = next;
}
}
});
}
export function ArticleView({
html,
onNavigate,
disabled = false,
}: {
html: string;
onNavigate: (title: string) => void;
disabled?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const onNavigateRef = useRef(onNavigate);
const disabledRef = useRef(disabled);
useEffect(() => { onNavigateRef.current = onNavigate; });
useEffect(() => { disabledRef.current = disabled; });
useEffect(() => {
const container = containerRef.current;
if (!container || !html) return;
container.innerHTML = html;
cleanWikiHtml(container);
container.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']").forEach((link) => {
const href = link.getAttribute("href") ?? "";
const path = href.replace("/wiki/", "");
let decoded: string;
try { decoded = decodeURIComponent(path); } catch { decoded = path; }
const title = decoded.replace(/_/g, " ");
if (FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) || title.includes("#")) {
link.removeAttribute("href");
return;
}
link.setAttribute("data-wiki-title", title);
link.removeAttribute("href");
link.classList.add("wiki-link");
});
container.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
link.removeAttribute("href");
});
const handleClick = (e: MouseEvent) => {
if (disabledRef.current) return;
const target = (e.target as HTMLElement).closest("[data-wiki-title]") as HTMLElement | null;
if (!target) return;
e.preventDefault();
const title = target.getAttribute("data-wiki-title");
if (title) onNavigateRef.current(title);
};
container.addEventListener("click", handleClick);
return () => container.removeEventListener("click", handleClick);
}, [html]);
return <div ref={containerRef} className="article-content" />;
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
type Mode = "login" | "register";
export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [mode, setMode] = useState<Mode>("login");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === "register") {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json() as { error?: string };
if (!res.ok) { setError(data.error ?? "Erreur"); return; }
}
const result = await signIn("credentials", {
email, password, redirect: false,
});
if (result?.error) { setError("Email ou mot de passe incorrect"); return; }
onSuccess();
} finally {
setLoading(false);
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
<div className="modal-tabs">
<button
className={`modal-tab ${mode === "login" ? "active" : ""}`}
onClick={() => { setMode("login"); setError(null); }}
>
Connexion
</button>
<button
className={`modal-tab ${mode === "register" ? "active" : ""}`}
onClick={() => { setMode("register"); setError(null); }}
>
Inscription
</button>
</div>
<form className="modal-form" onSubmit={handleSubmit}>
{mode === "register" && (
<input
className="input"
type="text"
placeholder="Pseudo"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={30}
/>
)}
<input
className="input"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
className="input"
type="password"
placeholder="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
{error && <div className="error-banner" style={{ marginBottom: 0 }}>{error}</div>}
<button className="btn btn-primary" type="submit" disabled={loading}>
{loading ? "Chargement..." : mode === "login" ? "Se connecter" : "Créer un compte"}
</button>
</form>
<button className="modal-close" onClick={onClose} aria-label="Fermer"></button>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { useRef } from "react";
export function Breadcrumbs({ history, endRef }: { history: string[]; endRef: React.RefObject<HTMLDivElement | null> }) {
return (
<div className="breadcrumb-trail">
{history.map((title, i) => (
<span key={i} className="breadcrumb-item">
{i > 0 && <span className="breadcrumb-sep"></span>}
<span className={i === history.length - 1 ? "breadcrumb-current" : "breadcrumb-past"}>
{title}
</span>
</span>
))}
<div ref={endRef} />
</div>
);
}
export function useBreadcrumbScroll() {
const endRef = useRef<HTMLDivElement>(null);
return endRef;
}
+158
View File
@@ -0,0 +1,158 @@
"use client";
import { useEffect, useRef } from "react";
import { ArticleView } from "./ArticleView";
import { Breadcrumbs } from "./Breadcrumbs";
import type { Room } from "../api/rooms/route";
type GameScreenProps = {
room: Room;
playerId: string;
html: string;
title: string;
loading: boolean;
loadError: string | null;
history: string[];
clicks: number;
elapsed: string;
countdown: number | null;
onNavigate: (title: string) => void;
onRetry: () => void;
onNextRound: () => void;
onResetGame: () => void;
};
export function GameScreen({
room, playerId, html, title, loading, loadError, history, clicks, elapsed,
countdown, onNavigate, onRetry, onNextRound, onResetGame,
}: GameScreenProps) {
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
const myPlayer = room.players.find((p) => p.id === playerId);
const isHost = myPlayer?.isHost ?? false;
const sortedPlayers = [...room.players].sort((a, b) => b.score - a.score);
const winner = room.roundWinner ? room.players.find((p) => p.id === room.roundWinner) : null;
useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
}, [history]);
if (room.phase === "results") {
return (
<div className="screen results-screen">
<div className="results-content">
<div className="results-winner-banner">
{winner ? (
<>
<div className="results-winner-emoji">Victoire !</div>
<div className="results-winner-name">{winner.name} a gagne la manche !</div>
</>
) : (
<div className="results-winner-name">Manche terminee !</div>
)}
</div>
<div className="results-path">
<span className="path-start">{room.startArticle}</span>
<span className="path-arrow"> </span>
<span className="path-end">{room.targetArticle}</span>
</div>
<div className="results-scoreboard">
<h3 className="results-title">Classement</h3>
<ul className="scoreboard-list">
{sortedPlayers.map((p, i) => (
<li key={p.id} className={`scoreboard-item ${p.id === playerId ? "me" : ""}`}>
<span className="scoreboard-rank">#{i + 1}</span>
<span className="scoreboard-name">{p.name}</span>
{p.hasWon && <span className="player-badge winner">Gagnant</span>}
<span className="scoreboard-score">{p.score} pts</span>
</li>
))}
</ul>
</div>
{isHost ? (
<div className="results-actions">
<button className="btn btn-primary" onClick={onNextRound}>Manche suivante</button>
<button className="btn btn-ghost" onClick={onResetGame}>Nouvelle partie</button>
</div>
) : (
<p className="lobby-waiting">En attente de l&apos;hote...</p>
)}
</div>
</div>
);
}
if (room.phase === "countdown") {
return (
<div className="screen countdown-screen">
<div className="countdown-content">
<div className="countdown-path">
<div className="countdown-article">
<span className="countdown-label">Depart</span>
<span className="countdown-article-name">{room.startArticle}</span>
</div>
<div className="countdown-arrow"></div>
<div className="countdown-article">
<span className="countdown-label">Cible</span>
<span className="countdown-article-name highlight">{room.targetArticle}</span>
</div>
</div>
<div className="countdown-number">
{countdown !== null && countdown > 0 ? countdown : "Partez !"}
</div>
</div>
</div>
);
}
return (
<div className="screen game-screen">
<div className="game-topbar">
<div className="topbar-trail-zone">
<div className="topbar-target-row">
<span className="topbar-label">CIBLE</span>
<span className="topbar-target-name">{room.targetArticle}</span>
</div>
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
</div>
<div className="topbar-stats">
<span className="stat">{elapsed}</span>
<span className="stat">{clicks} clics</span>
<span className="stat">{myPlayer?.score ?? 0} pts</span>
</div>
</div>
<div className="game-layout">
<div className="game-main">
{loading && (
<div className="article-loading"><div className="loading-spinner" /> Chargement...</div>
)}
{loadError && (
<div className="article-error">
<p>{loadError}</p>
<button className="btn btn-secondary" onClick={onRetry}>Reessayer</button>
</div>
)}
{!loading && !loadError && html && (
<div className="article-container">
<h1 className="article-title">{title}</h1>
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
</div>
)}
</div>
<aside className="game-sidebar">
<h4 className="sidebar-title">Joueurs</h4>
<ul className="sidebar-players">
{sortedPlayers.map((p) => (
<li key={p.id} className={`sidebar-player ${p.id === playerId ? "me" : ""}`}>
<span className="sidebar-player-name">{p.name}</span>
<span className="sidebar-player-score">{p.score} pts</span>
{p.hasWon && <span className="sidebar-player-won">Gagne !</span>}
</li>
))}
</ul>
</aside>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import type { Session } from "next-auth";
type HomeScreenProps = {
playerName: string;
setPlayerName: (v: string) => void;
joinCode: string;
setJoinCode: (v: string) => void;
error: string | null;
setError: (v: string | null) => void;
loading: boolean;
onCreateRoom: () => void;
onJoinRoom: () => void;
onSolo: () => void;
session: Session | null;
onShowAuth: () => void;
onShowProfile: () => void;
};
export function HomeScreen({
playerName, setPlayerName, joinCode, setJoinCode,
error, setError, loading, onCreateRoom, onJoinRoom, onSolo,
session, onShowAuth, onShowProfile,
}: HomeScreenProps) {
return (
<div className="screen home-screen">
<div className="home-topbar">
{session?.user ? (
<button className="btn-account" onClick={onShowProfile}>
<span className="account-avatar">{session.user.name?.[0]?.toUpperCase() ?? "?"}</span>
<span className="account-name">{session.user.name}</span>
</button>
) : (
<button className="btn btn-ghost btn-sm" onClick={onShowAuth}>
Connexion / Inscription
</button>
)}
</div>
<div className="home-hero">
<div className="home-logo">
<span className="logo-wiki">Wiki</span>
<span className="logo-race">Rush</span>
</div>
<p className="home-subtitle">
Navigue entre les articles Wikipedia pour atteindre la cible en premier !
</p>
</div>
{error && (
<div className="error-banner">
{error}
<button onClick={() => setError(null)} className="error-close">x</button>
</div>
)}
<div className="home-form">
<input
className="input"
type="text"
placeholder="Ton pseudo"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
maxLength={20}
onKeyDown={(e) => e.key === "Enter" && onCreateRoom()}
/>
<div className="home-actions">
<div className="home-section">
<h3>Multijoueur</h3>
<button className="btn btn-primary" onClick={onCreateRoom} disabled={loading}>
{loading ? "Creation..." : "Creer une partie"}
</button>
<div className="join-row">
<input
className="input input-code"
type="text"
placeholder="Code (ex: WIKI)"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase().slice(0, 4))}
maxLength={4}
onKeyDown={(e) => e.key === "Enter" && onJoinRoom()}
/>
<button className="btn btn-secondary" onClick={onJoinRoom} disabled={loading}>
Rejoindre
</button>
</div>
</div>
<div className="home-divider">ou</div>
<div className="home-section">
<h3>Solo</h3>
<button className="btn btn-ghost" onClick={onSolo}>
Jouer en solo
</button>
</div>
</div>
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import type { Room } from "../api/rooms/route";
type LobbyScreenProps = {
room: Room;
playerId: string;
error: string | null;
setError: (v: string | null) => void;
loading: boolean;
onLeave: () => void;
onStart: () => void;
onReset: () => void;
};
export function LobbyScreen({
room, playerId, error, setError, loading, onLeave, onStart, onReset,
}: LobbyScreenProps) {
const isHost = room.players.find((p) => p.id === playerId)?.isHost ?? false;
return (
<div className="screen lobby-screen">
<button className="btn btn-ghost btn-back" onClick={onLeave}>Quitter</button>
<div className="lobby-code-block">
<span className="lobby-code-label">Code de la salle</span>
<span className="lobby-code">{room.code}</span>
<span className="lobby-code-hint">Partage ce code avec tes amis !</span>
</div>
{error && (
<div className="error-banner">
{error}
<button onClick={() => setError(null)} className="error-close">x</button>
</div>
)}
<div className="lobby-players">
<h3 className="lobby-section-title">Joueurs ({room.players.length}/8)</h3>
<ul className="player-list">
{room.players.map((p) => (
<li key={p.id} className={`player-item ${p.id === playerId ? "me" : ""}`}>
<span className="player-name">{p.name}</span>
{p.isHost && <span className="player-badge host">Hote</span>}
{p.id === playerId && <span className="player-badge you">Toi</span>}
<span className="player-score">{p.score} pts</span>
</li>
))}
</ul>
</div>
{room.round > 0 && <div className="lobby-round-info">Manche {room.round} terminee</div>}
{isHost ? (
<div className="lobby-host-actions">
<button className="btn btn-primary" onClick={onStart} disabled={loading}>
{loading ? "Preparation..." : room.round === 0 ? "Demarrer la partie" : "Manche suivante"}
</button>
{room.round > 0 && (
<button className="btn btn-ghost" onClick={onReset}>Nouvelle partie (reset scores)</button>
)}
</div>
) : (
<p className="lobby-waiting">En attente que l&apos;hote demarre...</p>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { signOut } from "next-auth/react";
type Game = {
id: string;
mode: string;
startArticle: string;
targetArticle: string;
path: string[];
clicks: number;
timeSeconds: number;
won: boolean;
playedAt: string;
};
type Stats = {
total: number;
won: number;
avgClicks: number;
avgTime: number;
bestClicks: number;
bestTime: 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")}`;
}
function computeStats(games: Game[]): Stats {
const won = games.filter((g) => g.won);
return {
total: games.length,
won: won.length,
avgClicks: won.length ? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length) : 0,
avgTime: won.length ? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length : 0,
bestClicks: won.length ? Math.min(...won.map((g) => g.clicks)) : 0,
bestTime: won.length ? Math.min(...won.map((g) => g.timeSeconds)) : 0,
};
}
export function ProfileScreen({
userName,
onBack,
}: {
userName: string;
onBack: () => void;
}) {
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "solo" | "multi">("all");
useEffect(() => {
fetch("/api/games")
.then((r) => r.json())
.then((data) => setGames(data as Game[]))
.finally(() => setLoading(false));
}, []);
const filtered = filter === "all" ? games : games.filter((g) => g.mode === filter);
const stats = computeStats(filtered);
return (
<div className="screen profile-screen">
<div className="profile-header">
<button className="btn btn-ghost btn-back" onClick={onBack}> Retour</button>
<div className="profile-title">
<span className="profile-avatar">{userName[0].toUpperCase()}</span>
<h2 className="profile-name">{userName}</h2>
</div>
<button className="btn btn-ghost" onClick={() => signOut({ redirect: false }).then(onBack)}>
Déconnexion
</button>
</div>
<div className="profile-stats-grid">
<div className="stat-card">
<span className="stat-card-value">{stats.total}</span>
<span className="stat-card-label">Parties</span>
</div>
<div className="stat-card">
<span className="stat-card-value">{stats.won}</span>
<span className="stat-card-label">Victoires</span>
</div>
<div className="stat-card">
<span className="stat-card-value">{stats.avgClicks > 0 ? stats.avgClicks : "—"}</span>
<span className="stat-card-label">Clics moy.</span>
</div>
<div className="stat-card">
<span className="stat-card-value">{stats.avgTime > 0 ? fmt(stats.avgTime) : "—"}</span>
<span className="stat-card-label">Temps moy.</span>
</div>
<div className="stat-card accent">
<span className="stat-card-value">{stats.bestClicks > 0 ? stats.bestClicks : "—"}</span>
<span className="stat-card-label">Meilleur clics</span>
</div>
<div className="stat-card accent">
<span className="stat-card-value">{stats.bestTime > 0 ? fmt(stats.bestTime) : "—"}</span>
<span className="stat-card-label">Meilleur temps</span>
</div>
</div>
<div className="profile-filters">
{(["all", "solo", "multi"] as const).map((f) => (
<button
key={f}
className={`filter-btn ${filter === f ? "active" : ""}`}
onClick={() => setFilter(f)}
>
{f === "all" ? "Tout" : f === "solo" ? "Solo" : "Multi"}
</button>
))}
</div>
<div className="games-list">
{loading && <div className="article-loading"><div className="loading-spinner" /> Chargement...</div>}
{!loading && filtered.length === 0 && (
<p className="games-empty">Aucune partie enregistrée.</p>
)}
{filtered.map((g) => (
<div key={g.id} className={`game-card ${g.won ? "won" : "lost"}`}>
<div className="game-card-top">
<span className="game-card-mode">{g.mode === "solo" ? "Solo" : "Multi"}</span>
<span className="game-card-date">{new Date(g.playedAt).toLocaleDateString("fr-FR")}</span>
<span className={`game-card-result ${g.won ? "won" : "lost"}`}>{g.won ? "Victoire" : "Abandon"}</span>
</div>
<div className="game-card-route">
<span className="game-card-start">{g.startArticle}</span>
<span className="game-card-arrow"></span>
<span className="game-card-target">{g.targetArticle}</span>
</div>
{g.won && (
<div className="game-card-stats">
<span>{g.clicks} clics</span>
<span>{fmt(g.timeSeconds)}</span>
<span>{g.path.length - 1} articles parcourus</span>
</div>
)}
{g.path.length > 0 && (
<div className="game-card-path">
{g.path.map((t, i) => (
<span key={i} className="game-path-item">
{i > 0 && <span className="game-path-sep"></span>}
<span className={i === g.path.length - 1 && g.won ? "game-path-end" : ""}>{t}</span>
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef } from "react";
import { ArticleView } from "./ArticleView";
import { Breadcrumbs } from "./Breadcrumbs";
import type { Puzzle } from "../../lib/types";
type SoloPhase = "setup" | "playing" | "won";
type SoloScreenProps = {
phase: SoloPhase;
puzzle: Puzzle | null;
html: string;
title: string;
loading: boolean;
loadError: string | null;
history: string[];
clicks: number;
elapsedDisplay: string;
canGoBack: boolean;
onStart: () => void;
onNavigate: (title: string) => void;
onBack: () => void;
onQuit: () => void;
onNewGame: () => void;
onRetry: () => void;
};
function fmt(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, "0")}`;
}
export function SoloScreen({
phase, puzzle, html, title, loading, loadError, history, clicks,
elapsedDisplay, canGoBack, onStart, onNavigate, onBack, onQuit, onNewGame, onRetry,
}: SoloScreenProps) {
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
}, [history]);
return (
<div className="screen solo-screen">
{phase === "setup" && (
<div className="center-content">
<button className="btn btn-ghost btn-back" onClick={onQuit}>Retour</button>
<h2 className="section-title">Mode Solo</h2>
<p className="section-desc">
Deux articles aleatoires seront choisis. Atteins l&apos;article cible en cliquant uniquement sur les liens !
</p>
<button className="btn btn-primary" onClick={onStart} disabled={loading}>
{loading ? "Preparation..." : "Lancer une partie"}
</button>
</div>
)}
{phase === "playing" && (
<>
<div className="game-main">
<div className="article-container">
{title && <h1 className="article-title">{title}</h1>}
{loading && (
<div className="article-loading">
<div className="loading-spinner" />
Chargement...
</div>
)}
{loadError && (
<div className="article-error">
<p>{loadError}</p>
<button className="btn btn-secondary" onClick={onRetry}>Reessayer</button>
</div>
)}
{!loading && !loadError && html && (
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
)}
</div>
</div>
<div className="solo-bottombar">
<div className="breadcrumb-zone">
<span className="breadcrumb-label">VOUS DEVEZ TROUVER</span>
<span className="breadcrumb-target">{puzzle?.target}</span>
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
</div>
<div className="solo-stats">
<div className="solo-stat">
<span className="solo-stat-label">TEMPS</span>
<span className="solo-stat-value">{elapsedDisplay}</span>
</div>
<div className="solo-stat">
<span className="solo-stat-label">CLICS</span>
<span className="solo-stat-value">{clicks}</span>
</div>
{canGoBack && (
<button className="btn btn-ghost solo-back-btn" onClick={onBack} disabled={loading}>
&larr; Retour <span className="solo-back-cost">+1</span>
</button>
)}
<button className="btn btn-ghost solo-quit" onClick={onQuit}>
Quitter
</button>
</div>
</div>
</>
)}
{phase === "won" && (
<div className="victory-screen">
<div className="victory-content">
<div className="victory-emoji">Bravo !</div>
<h2 className="victory-title">Article atteint !</h2>
<div className="victory-stats">
<div className="victory-stat">
<span className="victory-stat-value">{clicks}</span>
<span className="victory-stat-label">clics</span>
</div>
<div className="victory-stat">
<span className="victory-stat-value">{elapsedDisplay}</span>
<span className="victory-stat-label">temps</span>
</div>
</div>
<div className="victory-path-full">
{history.map((t, i) => (
<span key={i} className="victory-path-item">
{i > 0 && <span className="victory-path-sep"></span>}
<span className={i === history.length - 1 ? "victory-path-end" : "victory-path-step"}>{t}</span>
</span>
))}
</div>
<div className="victory-actions">
<button className="btn btn-primary" onClick={onNewGame}>Nouvelle partie</button>
<button className="btn btn-ghost" onClick={onQuit}>Accueil</button>
</div>
</div>
</div>
)}
</div>
);
}
+1517 -15
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./components/Providers";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "WikiRush - Jeu de navigation Wikipedia",
description: "Navigue entre les articles Wikipedia pour atteindre la cible en premier !",
};
export default function RootLayout({
@@ -24,10 +25,12 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col" style={{ background: "#0f0f0f", color: "#f0f0f0" }}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+238 -63
View File
@@ -1,65 +1,240 @@
import Image from "next/image";
"use client";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import type { Screen } from "../lib/types";
import { useSoloGame, useSoloKeyboard } from "../lib/useSoloGame";
import { useMultiGame } from "../lib/useMultiGame";
import { loadSession, clearSession } from "../lib/session";
import { HomeScreen } from "./components/HomeScreen";
import { LobbyScreen } from "./components/LobbyScreen";
import { SoloScreen } from "./components/SoloScreen";
import { GameScreen } from "./components/GameScreen";
import { AuthModal } from "./components/AuthModal";
import { ProfileScreen } from "./components/ProfileScreen";
function fmt(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, "0")}`;
}
async function saveGame(data: {
mode: string;
startArticle: string;
targetArticle: string;
path: string[];
clicks: number;
timeSeconds: number;
won: boolean;
}) {
try {
await fetch("/api/games", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} catch { /* silencieux — pas de compte ou hors ligne */ }
}
export default function WikiRush() {
const { data: session } = useSession();
const [screen, setScreen] = useState<Screen>("home");
const [playerName, setPlayerName] = useState("");
const [joinCode, setJoinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showAuth, setShowAuth] = useState(false);
const solo = useSoloGame();
const multi = useMultiGame();
// Pré-remplir le pseudo avec le nom du compte connecté
useEffect(() => {
if (session?.user?.name && !playerName) setPlayerName(session.user.name);
}, [session]); // eslint-disable-line react-hooks/exhaustive-deps
// Restaurer la session apres F5
useEffect(() => {
const saved = loadSession();
if (!saved) return;
if (saved.screen === "solo" && saved.soloPuzzle && saved.soloHistory?.length) {
setScreen("solo");
solo.restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0)
.then((ok) => { if (!ok) { clearSession(); setScreen("home"); } });
} else if (
(saved.screen === "lobby" || saved.screen === "game") &&
saved.multiRoomCode && saved.multiPlayerId
) {
if (saved.playerName) setPlayerName(saved.playerName);
multi.restore(saved.multiRoomCode, saved.multiPlayerId).then((ok) => {
if (ok) setScreen("lobby");
else { clearSession(); setScreen("home"); }
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Bloquer le bouton retour navigateur
useEffect(() => {
const onPop = () => history.pushState(null, "", window.location.href);
history.pushState(null, "", window.location.href);
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// Backspace = retour arriere en solo
useSoloKeyboard(screen === "solo" && solo.phase === "playing", solo.goBack);
// Sync ecran quand la room change de phase
useEffect(() => {
if (!multi.room) return;
const { phase } = multi.room;
queueMicrotask(() => {
if (phase === "countdown" || phase === "playing") setScreen("game");
});
}, [multi.room]);
// Sauvegarder partie solo terminée
useEffect(() => {
if (solo.phase !== "won" || !solo.puzzle) return;
saveGame({
mode: "solo",
startArticle: solo.puzzle.start,
targetArticle: solo.puzzle.target,
path: solo.history,
clicks: solo.clicks,
timeSeconds: solo.elapsed,
won: true,
});
}, [solo.phase]); // eslint-disable-line react-hooks/exhaustive-deps
// ---- Actions home ----
async function handleCreateRoom() {
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
setLoading(true); setError(null);
const { error: err } = await multi.createRoom(playerName.trim());
setLoading(false);
if (err) { setError(err); return; }
setScreen("lobby");
}
async function handleJoinRoom() {
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
if (joinCode.trim().length !== 4) { setError("Le code doit faire 4 lettres"); return; }
setLoading(true); setError(null);
const { error: err } = await multi.joinRoom(playerName.trim(), joinCode.trim().toUpperCase());
setLoading(false);
if (err) { setError(err); return; }
setScreen("lobby");
}
async function handleStartGame() {
setLoading(true); setError(null);
const { error: err } = await multi.startGame();
setLoading(false);
if (err) setError(err);
}
async function handleNextRound() {
await multi.nextRound();
setScreen("lobby");
}
async function handleResetGame() {
await multi.resetGame();
setScreen("lobby");
}
function handleLeave() {
multi.leave();
setScreen("home");
}
// ---- Rendu ----
if (screen === "profile") {
return (
<ProfileScreen
userName={session?.user?.name ?? "Joueur"}
onBack={() => setScreen("home")}
/>
);
}
if (screen === "home") {
return (
<>
<HomeScreen
playerName={playerName} setPlayerName={setPlayerName}
joinCode={joinCode} setJoinCode={setJoinCode}
error={error} setError={setError} loading={loading}
onCreateRoom={handleCreateRoom}
onJoinRoom={handleJoinRoom}
onSolo={() => { solo.reset(); setScreen("solo"); }}
session={session}
onShowAuth={() => setShowAuth(true)}
onShowProfile={() => setScreen("profile")}
/>
{showAuth && (
<AuthModal
onClose={() => setShowAuth(false)}
onSuccess={() => setShowAuth(false)}
/>
)}
</>
);
}
if (screen === "solo") {
return (
<SoloScreen
phase={solo.phase} puzzle={solo.puzzle}
html={solo.html} title={solo.title}
loading={solo.loading} loadError={solo.loadError}
history={solo.history} clicks={solo.clicks}
elapsedDisplay={fmt(solo.elapsed)}
canGoBack={solo.canGoBack}
onStart={solo.start}
onNavigate={solo.navigate}
onBack={solo.goBack}
onQuit={() => { solo.reset(); setScreen("home"); }}
onNewGame={solo.start}
onRetry={solo.retryLoad}
/>
);
}
if (screen === "lobby" && multi.room && multi.playerId) {
return (
<LobbyScreen
room={multi.room} playerId={multi.playerId}
error={error} setError={setError} loading={loading}
onLeave={handleLeave}
onStart={handleStartGame}
onReset={handleResetGame}
/>
);
}
if (screen === "game" && multi.room && multi.playerId) {
return (
<GameScreen
room={multi.room} playerId={multi.playerId}
html={multi.html} title={multi.title}
loading={multi.loading} loadError={multi.loadError}
history={multi.history} clicks={multi.clicks}
elapsed={fmt(multi.elapsed)}
countdown={multi.countdown}
onNavigate={multi.navigate}
onRetry={multi.retryLoad}
onNextRound={handleNextRound}
onResetGame={handleResetGame}
/>
);
}
return null;
}