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,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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'hote demarre...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -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'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}>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user