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
+1
View File
@@ -0,0 +1 @@
enable-pre-post-scripts=true
+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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "./components/Providers";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "WikiRush - Jeu de navigation Wikipedia",
description: "Generated by create next app", description: "Navigue entre les articles Wikipedia pour atteindre la cible en premier !",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,10 +25,12 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} 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> </html>
); );
} }
+238 -63
View File
@@ -1,65 +1,240 @@
import Image from "next/image"; "use client";
export default function Home() { import { useState, useEffect } from "react";
return ( import { useSession } from "next-auth/react";
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> import type { Screen } from "../lib/types";
<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"> import { useSoloGame, useSoloKeyboard } from "../lib/useSoloGame";
<Image import { useMultiGame } from "../lib/useMultiGame";
className="dark:invert" import { loadSession, clearSession } from "../lib/session";
src="/next.svg" import { HomeScreen } from "./components/HomeScreen";
alt="Next.js logo" import { LobbyScreen } from "./components/LobbyScreen";
width={100} import { SoloScreen } from "./components/SoloScreen";
height={20} import { GameScreen } from "./components/GameScreen";
priority import { AuthModal } from "./components/AuthModal";
/> import { ProfileScreen } from "./components/ProfileScreen";
<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"> function fmt(s: number): string {
To get started, edit the page.tsx file. const m = Math.floor(s / 60);
</h1> const sec = Math.floor(s % 60);
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> return `${m}:${String(sec).padStart(2, "0")}`;
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" async function saveGame(data: {
className="font-medium text-zinc-950 dark:text-zinc-50" mode: string;
> startArticle: string;
Templates targetArticle: string;
</a>{" "} path: string[];
or the{" "} clicks: number;
<a timeSeconds: number;
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" won: boolean;
className="font-medium text-zinc-950 dark:text-zinc-50" }) {
> try {
Learning await fetch("/api/games", {
</a>{" "} method: "POST",
center. headers: { "Content-Type": "application/json" },
</p> body: JSON.stringify(data),
</div> });
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> } catch { /* silencieux — pas de compte ou hors ligne */ }
<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" export default function WikiRush() {
target="_blank" const { data: session } = useSession();
rel="noopener noreferrer" const [screen, setScreen] = useState<Screen>("home");
> const [playerName, setPlayerName] = useState("");
<Image const [joinCode, setJoinCode] = useState("");
className="dark:invert" const [error, setError] = useState<string | null>(null);
src="/vercel.svg" const [loading, setLoading] = useState(false);
alt="Vercel logomark" const [showAuth, setShowAuth] = useState(false);
width={16}
height={16} const solo = useSoloGame();
/> const multi = useMultiGame();
Deploy Now
</a> // Pré-remplir le pseudo avec le nom du compte connecté
<a useEffect(() => {
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]" if (session?.user?.name && !playerName) setPlayerName(session.user.name);
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" }, [session]); // eslint-disable-line react-hooks/exhaustive-deps
target="_blank"
rel="noopener noreferrer" // Restaurer la session apres F5
> useEffect(() => {
Documentation const saved = loadSession();
</a> if (!saved) return;
</div>
</main> if (saved.screen === "solo" && saved.soloPuzzle && saved.soloHistory?.length) {
</div> 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;
} }
+39
View File
@@ -0,0 +1,39 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "./lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) return null;
const valid = await bcrypt.compare(credentials.password as string, user.password);
if (!valid) return null;
return { id: user.id, name: user.name, email: user.email };
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/",
},
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
if (session.user) session.user.id = token.id as string;
return session;
},
},
});
+29
View File
@@ -0,0 +1,29 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }
export * as $Enums from './enums'
export * from './enums';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Game
*
*/
export type Game = Prisma.GameModel
+53
View File
@@ -0,0 +1,53 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from './enums'
export * from "./enums"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Game
*
*/
export type Game = Prisma.GameModel
+263
View File
@@ -0,0 +1,263 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import type * as Prisma from "./internal/prismaNamespace"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
+15
View File
@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,826 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the client.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/client"
import type * as Prisma from "../models"
import { type PrismaClient } from "./class"
export type * from '../models'
export type DMMF = typeof runtime.DMMF
export type PrismaPromise<T> = runtime.Types.Public.PrismaPromise<T>
/**
* Prisma Errors
*/
export const PrismaClientKnownRequestError = runtime.PrismaClientKnownRequestError
export type PrismaClientKnownRequestError = runtime.PrismaClientKnownRequestError
export const PrismaClientUnknownRequestError = runtime.PrismaClientUnknownRequestError
export type PrismaClientUnknownRequestError = runtime.PrismaClientUnknownRequestError
export const PrismaClientRustPanicError = runtime.PrismaClientRustPanicError
export type PrismaClientRustPanicError = runtime.PrismaClientRustPanicError
export const PrismaClientInitializationError = runtime.PrismaClientInitializationError
export type PrismaClientInitializationError = runtime.PrismaClientInitializationError
export const PrismaClientValidationError = runtime.PrismaClientValidationError
export type PrismaClientValidationError = runtime.PrismaClientValidationError
/**
* Re-export of sql-template-tag
*/
export const sql = runtime.sqltag
export const empty = runtime.empty
export const join = runtime.join
export const raw = runtime.raw
export const Sql = runtime.Sql
export type Sql = runtime.Sql
/**
* Decimal.js
*/
export const Decimal = runtime.Decimal
export type Decimal = runtime.Decimal
export type DecimalJsLike = runtime.DecimalJsLike
/**
* Extensions
*/
export type Extension = runtime.Types.Extensions.UserArgs
export const getExtensionContext = runtime.Extensions.getExtensionContext
export type Args<T, F extends runtime.Operation> = runtime.Types.Public.Args<T, F>
export type Payload<T, F extends runtime.Operation = never> = runtime.Types.Public.Payload<T, F>
export type Result<T, A, F extends runtime.Operation> = runtime.Types.Public.Result<T, A, F>
export type Exact<A, W> = runtime.Types.Public.Exact<A, W>
export type PrismaVersion = {
client: string
engine: string
}
/**
* Prisma Client JS version: 7.7.0
* Query Engine version: 75cbdc1eb7150937890ad5465d861175c6624711
*/
export const prismaVersion: PrismaVersion = {
client: "7.7.0",
engine: "75cbdc1eb7150937890ad5465d861175c6624711"
}
/**
* Utility Types
*/
export type Bytes = runtime.Bytes
export type JsonObject = runtime.JsonObject
export type JsonArray = runtime.JsonArray
export type JsonValue = runtime.JsonValue
export type InputJsonObject = runtime.InputJsonObject
export type InputJsonArray = runtime.InputJsonArray
export type InputJsonValue = runtime.InputJsonValue
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
type SelectAndInclude = {
select: any
include: any
}
type SelectAndOmit = {
select: any
omit: any
}
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Prisma__Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
export type Enumerable<T> = T | Array<T>;
/**
* Subset
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection
*/
export type Subset<T, U> = {
[key in keyof T]: key extends keyof U ? T[key] : never;
};
/**
* SelectSubset
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
* Additionally, it validates, if both select and include are present. If the case, it errors.
*/
export type SelectSubset<T, U> = {
[key in keyof T]: key extends keyof U ? T[key] : never
} &
(T extends SelectAndInclude
? 'Please either choose `select` or `include`.'
: T extends SelectAndOmit
? 'Please either choose `select` or `omit`.'
: {})
/**
* Subset + Intersection
* @desc From `T` pick properties that exist in `U` and intersect `K`
*/
export type SubsetIntersection<T, U, K> = {
[key in keyof T]: key extends keyof U ? T[key] : never
} &
K
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
/**
* XOR is needed to have a real mutually exclusive union type
* https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types
*/
export type XOR<T, U> =
T extends object ?
U extends object ?
(Without<T, U> & U) | (Without<U, T> & T)
: U : T
/**
* Is T a Record?
*/
type IsObject<T extends any> = T extends Array<any>
? False
: T extends Date
? False
: T extends Uint8Array
? False
: T extends BigInt
? False
: T extends object
? True
: False
/**
* If it's T[], return T
*/
export type UnEnumerate<T extends unknown> = T extends Array<infer U> ? U : T
/**
* From ts-toolbelt
*/
type __Either<O extends object, K extends Key> = Omit<O, K> &
{
// Merge all but K
[P in K]: Prisma__Pick<O, P & keyof O> // With K possibilities
}[K]
type EitherStrict<O extends object, K extends Key> = Strict<__Either<O, K>>
type EitherLoose<O extends object, K extends Key> = ComputeRaw<__Either<O, K>>
type _Either<
O extends object,
K extends Key,
strict extends Boolean
> = {
1: EitherStrict<O, K>
0: EitherLoose<O, K>
}[strict]
export type Either<
O extends object,
K extends Key,
strict extends Boolean = 1
> = O extends unknown ? _Either<O, K, strict> : never
export type Union = any
export type PatchUndefined<O extends object, O1 extends object> = {
[K in keyof O]: O[K] extends undefined ? At<O1, K> : O[K]
} & {}
/** Helper Types for "Merge" **/
export type IntersectOf<U extends Union> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
export type Overwrite<O extends object, O1 extends object> = {
[K in keyof O]: K extends keyof O1 ? O1[K] : O[K];
} & {};
type _Merge<U extends object> = IntersectOf<Overwrite<U, {
[K in keyof U]-?: At<U, K>;
}>>;
type Key = string | number | symbol;
type AtStrict<O extends object, K extends Key> = O[K & keyof O];
type AtLoose<O extends object, K extends Key> = O extends unknown ? AtStrict<O, K> : never;
export type At<O extends object, K extends Key, strict extends Boolean = 1> = {
1: AtStrict<O, K>;
0: AtLoose<O, K>;
}[strict];
export type ComputeRaw<A extends any> = A extends Function ? A : {
[K in keyof A]: A[K];
} & {};
export type OptionalFlat<O> = {
[K in keyof O]?: O[K];
} & {};
type _Record<K extends keyof any, T> = {
[P in K]: T;
};
// cause typescript not to expand types and preserve names
type NoExpand<T> = T extends unknown ? T : never;
// this type assumes the passed object is entirely optional
export type AtLeast<O extends object, K extends string> = NoExpand<
O extends unknown
? | (K extends keyof O ? { [P in K]: O[P] } & O : O)
| {[P in keyof O as P extends K ? P : never]-?: O[P]} & O
: never>;
type _Strict<U, _U = U> = U extends unknown ? U & OptionalFlat<_Record<Exclude<Keys<_U>, keyof U>, never>> : never;
export type Strict<U extends object> = ComputeRaw<_Strict<U>>;
/** End Helper Types for "Merge" **/
export type Merge<U extends object> = ComputeRaw<_Merge<Strict<U>>>;
export type Boolean = True | False
export type True = 1
export type False = 0
export type Not<B extends Boolean> = {
0: 1
1: 0
}[B]
export type Extends<A1 extends any, A2 extends any> = [A1] extends [never]
? 0 // anything `never` is false
: A1 extends A2
? 1
: 0
export type Has<U extends Union, U1 extends Union> = Not<
Extends<Exclude<U1, U>, U1>
>
export type Or<B1 extends Boolean, B2 extends Boolean> = {
0: {
0: 0
1: 1
}
1: {
0: 1
1: 1
}
}[B1][B2]
export type Keys<U extends Union> = U extends unknown ? keyof U : never
export type GetScalarType<T, O> = O extends object ? {
[P in keyof T]: P extends keyof O
? O[P]
: never
} : never
type FieldPaths<
T,
U = Omit<T, '_avg' | '_sum' | '_count' | '_min' | '_max'>
> = IsObject<T> extends True ? U : T
export type GetHavingFields<T> = {
[K in keyof T]: Or<
Or<Extends<'OR', K>, Extends<'AND', K>>,
Extends<'NOT', K>
> extends True
? // infer is only needed to not hit TS limit
// based on the brilliant idea of Pierre-Antoine Mills
// https://github.com/microsoft/TypeScript/issues/30188#issuecomment-478938437
T[K] extends infer TK
? GetHavingFields<UnEnumerate<TK> extends object ? Merge<UnEnumerate<TK>> : never>
: never
: {} extends FieldPaths<T[K]>
? never
: K
}[keyof T]
/**
* Convert tuple to union
*/
type _TupleToUnion<T> = T extends (infer E)[] ? E : never
type TupleToUnion<K extends readonly any[]> = _TupleToUnion<K>
export type MaybeTupleToUnion<T> = T extends any[] ? TupleToUnion<T> : T
/**
* Like `Pick`, but additionally can also accept an array of keys
*/
export type PickEnumerable<T, K extends Enumerable<keyof T> | keyof T> = Prisma__Pick<T, MaybeTupleToUnion<K>>
/**
* Exclude all keys with underscores
*/
export type ExcludeUnderscoreKeys<T extends string> = T extends `_${string}` ? never : T
export type FieldRef<Model, FieldType> = runtime.FieldRef<Model, FieldType>
type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRef<Model, FieldType>
export const ModelName = {
User: 'User',
Game: 'Game'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
export interface TypeMapCb<GlobalOmitOptions = {}> extends runtime.Types.Utils.Fn<{extArgs: runtime.Types.Extensions.InternalArgs }, runtime.Types.Utils.Record<string, any>> {
returns: TypeMap<this['params']['extArgs'], GlobalOmitOptions>
}
export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> = {
globalOmitOptions: {
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "game"
txIsolationLevel: TransactionIsolationLevel
}
model: {
User: {
payload: Prisma.$UserPayload<ExtArgs>
fields: Prisma.UserFieldRefs
operations: {
findUnique: {
args: Prisma.UserFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload> | null
}
findUniqueOrThrow: {
args: Prisma.UserFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
findFirst: {
args: Prisma.UserFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload> | null
}
findFirstOrThrow: {
args: Prisma.UserFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
findMany: {
args: Prisma.UserFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>[]
}
create: {
args: Prisma.UserCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
createMany: {
args: Prisma.UserCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.UserCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>[]
}
delete: {
args: Prisma.UserDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
update: {
args: Prisma.UserUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
deleteMany: {
args: Prisma.UserDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.UserUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.UserUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>[]
}
upsert: {
args: Prisma.UserUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$UserPayload>
}
aggregate: {
args: Prisma.UserAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateUser>
}
groupBy: {
args: Prisma.UserGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.UserGroupByOutputType>[]
}
count: {
args: Prisma.UserCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.UserCountAggregateOutputType> | number
}
}
}
Game: {
payload: Prisma.$GamePayload<ExtArgs>
fields: Prisma.GameFieldRefs
operations: {
findUnique: {
args: Prisma.GameFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload> | null
}
findUniqueOrThrow: {
args: Prisma.GameFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
findFirst: {
args: Prisma.GameFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload> | null
}
findFirstOrThrow: {
args: Prisma.GameFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
findMany: {
args: Prisma.GameFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>[]
}
create: {
args: Prisma.GameCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
createMany: {
args: Prisma.GameCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.GameCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>[]
}
delete: {
args: Prisma.GameDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
update: {
args: Prisma.GameUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
deleteMany: {
args: Prisma.GameDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.GameUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.GameUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>[]
}
upsert: {
args: Prisma.GameUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GamePayload>
}
aggregate: {
args: Prisma.GameAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateGame>
}
groupBy: {
args: Prisma.GameGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GameGroupByOutputType>[]
}
count: {
args: Prisma.GameCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GameCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
payload: any
operations: {
$executeRaw: {
args: [query: TemplateStringsArray | Sql, ...values: any[]],
result: any
}
$executeRawUnsafe: {
args: [query: string, ...values: any[]],
result: any
}
$queryRaw: {
args: [query: TemplateStringsArray | Sql, ...values: any[]],
result: any
}
$queryRawUnsafe: {
args: [query: string, ...values: any[]],
result: any
}
}
}
}
/**
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
password: 'password',
createdAt: 'createdAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const GameScalarFieldEnum = {
id: 'id',
userId: 'userId',
mode: 'mode',
startArticle: 'startArticle',
targetArticle: 'targetArticle',
path: 'path',
clicks: 'clicks',
timeSeconds: 'timeSeconds',
won: 'won',
playedAt: 'playedAt'
} as const
export type GameScalarFieldEnum = (typeof GameScalarFieldEnum)[keyof typeof GameScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
/**
* Field references
*/
/**
* Reference to a field of type 'String'
*/
export type StringFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'String'>
/**
* Reference to a field of type 'DateTime'
*/
export type DateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'DateTime'>
/**
* Reference to a field of type 'Int'
*/
export type IntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Int'>
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Boolean'
*/
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
export type BatchPayload = {
count: number
}
export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>
export type DefaultPrismaClient = PrismaClient
export type ErrorFormat = 'pretty' | 'colorless' | 'minimal'
export type PrismaClientOptions = ({
/**
* Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
*/
adapter: runtime.SqlDriverAdapterFactory
accelerateUrl?: never
} | {
/**
* Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
*/
accelerateUrl: string
adapter?: never
}) & {
/**
* @default "colorless"
*/
errorFormat?: ErrorFormat
/**
* @example
* ```
* // Shorthand for `emit: 'stdout'`
* log: ['query', 'info', 'warn', 'error']
*
* // Emit as events only
* log: [
* { emit: 'event', level: 'query' },
* { emit: 'event', level: 'info' },
* { emit: 'event', level: 'warn' }
* { emit: 'event', level: 'error' }
* ]
*
* / Emit as events and log to stdout
* og: [
* { emit: 'stdout', level: 'query' },
* { emit: 'stdout', level: 'info' },
* { emit: 'stdout', level: 'warn' }
* { emit: 'stdout', level: 'error' }
*
* ```
* Read more in our [docs](https://pris.ly/d/logging).
*/
log?: (LogLevel | LogDefinition)[]
/**
* The default values for transactionOptions
* maxWait ?= 2000
* timeout ?= 5000
*/
transactionOptions?: {
maxWait?: number
timeout?: number
isolationLevel?: TransactionIsolationLevel
}
/**
* Global configuration for omitting model fields by default.
*
* @example
* ```
* const prisma = new PrismaClient({
* omit: {
* user: {
* password: true
* }
* }
* })
* ```
*/
omit?: GlobalOmitConfig
/**
* SQL commenter plugins that add metadata to SQL queries as comments.
* Comments follow the sqlcommenter format: https://google.github.io/sqlcommenter/
*
* @example
* ```
* const prisma = new PrismaClient({
* adapter,
* comments: [
* traceContext(),
* queryInsights(),
* ],
* })
* ```
*/
comments?: runtime.SqlCommenterPlugin[]
}
export type GlobalOmitConfig = {
user?: Prisma.UserOmit
game?: Prisma.GameOmit
}
/* Types for Logging */
export type LogLevel = 'info' | 'query' | 'warn' | 'error'
export type LogDefinition = {
level: LogLevel
emit: 'stdout' | 'event'
}
export type CheckIsLogLevel<T> = T extends LogLevel ? T : never;
export type GetLogType<T> = CheckIsLogLevel<
T extends LogDefinition ? T['level'] : T
>;
export type GetEvents<T extends any[]> = T extends Array<LogLevel | LogDefinition>
? GetLogType<T[number]>
: never;
export type QueryEvent = {
timestamp: Date
query: string
params: string
duration: number
target: string
}
export type LogEvent = {
timestamp: Date
message: string
target: string
}
/* End Types for Logging */
export type PrismaAction =
| 'findUnique'
| 'findUniqueOrThrow'
| 'findMany'
| 'findFirst'
| 'findFirstOrThrow'
| 'create'
| 'createMany'
| 'createManyAndReturn'
| 'update'
| 'updateMany'
| 'updateManyAndReturn'
| 'upsert'
| 'delete'
| 'deleteMany'
| 'executeRaw'
| 'queryRaw'
| 'aggregate'
| 'count'
| 'runCommandRaw'
| 'findRaw'
| 'groupBy'
/**
* `PrismaClient` proxy available in interactive transactions.
*/
export type TransactionClient = Omit<DefaultPrismaClient, runtime.ITXClientDenyList>
@@ -0,0 +1,104 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models'
export type * from './prismaNamespace'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Game: 'Game'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
password: 'password',
createdAt: 'createdAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const GameScalarFieldEnum = {
id: 'id',
userId: 'userId',
mode: 'mode',
startArticle: 'startArticle',
targetArticle: 'targetArticle',
path: 'path',
clicks: 'clicks',
timeSeconds: 'timeSeconds',
won: 'won',
playedAt: 'playedAt'
} as const
export type GameScalarFieldEnum = (typeof GameScalarFieldEnum)[keyof typeof GameScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
+13
View File
@@ -0,0 +1,13 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User'
export type * from './models/Game'
export type * from './commonInputTypes'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import Database from "better-sqlite3";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "./generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
function createPrisma() {
const adapter = new PrismaBetterSqlite3({ url: "./wikirace.db" });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrisma();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Puzzle } from "./types";
export const FALLBACK_PUZZLES: Puzzle[] = [
{ start: "Pizza", target: "Egypte antique" },
{ start: "Michael Jackson", target: "Mont Everest" },
{ start: "Echecs", target: "Amazonie" },
{ start: "Titanic (film)", target: "Trou noir" },
{ start: "Football", target: "William Shakespeare" },
{ start: "Harry Potter", target: "Grande Muraille de Chine" },
{ start: "Albert Einstein", target: "Jazz" },
{ start: "Tour Eiffel", target: "Genetique" },
{ start: "Leonard de Vinci", target: "Eruption volcanique" },
{ start: "The Beatles", target: "Bouddhisme" },
{ start: "Dinosaure", target: "Internet" },
{ start: "Napoleon Ier", target: "Musique de jazz" },
{ start: "Cleopatre", target: "Exploration spatiale" },
{ start: "Wolfgang Amadeus Mozart", target: "Foret tropicale" },
{ start: "Isaac Newton", target: "Arts martiaux" },
{ start: "Charles Darwin", target: "Jeux olympiques" },
{ start: "Marie Curie", target: "Hip-hop" },
{ start: "Abraham Lincoln", target: "Recif corallien" },
{ start: "Ludwig van Beethoven", target: "Photographie" },
{ start: "Vincent van Gogh", target: "Tectonique des plaques" },
{ start: "Galilee", target: "Folklore" },
{ start: "Nikola Tesla", target: "Yoga" },
{ start: "Aristote", target: "Television" },
{ start: "Platon", target: "Cinema" },
{ start: "Karl Marx", target: "Surf" },
{ start: "Sigmund Freud", target: "Architecture" },
{ start: "Mahatma Gandhi", target: "Antarctique" },
{ start: "Nelson Mandela", target: "Jazz" },
{ start: "Che Guevara", target: "Sushi" },
{ start: "Barack Obama", target: "Musique classique" },
{ start: "Steve Jobs", target: "Foret amazonienne" },
{ start: "Elon Musk", target: "Dinosaure" },
{ start: "Beyonce", target: "Empire romain" },
{ start: "Taylor Swift", target: "Vikings" },
{ start: "Eminem", target: "Route de la soie" },
{ start: "Bob Dylan", target: "Samurai" },
{ start: "Freddie Mercury", target: "Fleuve Amazone" },
{ start: "David Bowie", target: "Bouddhisme" },
{ start: "Elvis Presley", target: "Mont Fuji" },
{ start: "John Lennon", target: "Trou noir" },
{ start: "Led Zeppelin", target: "Ocean" },
{ start: "Pink Floyd", target: "Democratie" },
{ start: "Nirvana (groupe)", target: "Azteques" },
{ start: "Michel-Ange", target: "Recif corallien" },
{ start: "Raphael (peintre)", target: "Rome antique" },
{ start: "Pablo Picasso", target: "Mythologie nordique" },
{ start: "Frida Kahlo", target: "Age viking" },
{ start: "Salvador Dali", target: "Mecanique quantique" },
{ start: "Andy Warhol", target: "Grande Barriere de corail" },
{ start: "Bruce Lee", target: "Mythologie grecque" },
{ start: "Muhammad Ali", target: "Route de la soie" },
{ start: "Usain Bolt", target: "Revolution francaise" },
{ start: "Serena Williams", target: "Chine antique" },
{ start: "France", target: "Japon" },
{ start: "Paris", target: "Astronomie" },
{ start: "Renaissance", target: "Biologie" },
{ start: "Philosophie", target: "Geographie" },
{ start: "Mathematiques", target: "Musique" },
{ start: "Physique", target: "Litterature" },
{ start: "Chimie", target: "Histoire" },
];
export function getFallbackPuzzle(): Puzzle {
return FALLBACK_PUZZLES[Math.floor(Math.random() * FALLBACK_PUZZLES.length)];
}
+34
View File
@@ -0,0 +1,34 @@
// Persistence sessionStorage pour F5 / rechargement de page
const KEY = "wikirace_session";
export type SessionData = {
screen: "solo" | "lobby" | "game";
// Solo
soloPuzzle?: { start: string; target: string };
soloHistory?: string[];
soloClicks?: number;
// Multi
multiRoomCode?: string;
multiPlayerId?: string;
playerName?: string;
};
export function saveSession(data: SessionData) {
try {
sessionStorage.setItem(KEY, JSON.stringify(data));
} catch { /* ignore quota */ }
}
export function loadSession(): SessionData | null {
try {
const raw = sessionStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as SessionData) : null;
} catch {
return null;
}
}
export function clearSession() {
try { sessionStorage.removeItem(KEY); } catch { /* ignore */ }
}
+11
View File
@@ -0,0 +1,11 @@
export type Screen = "home" | "lobby" | "game" | "solo" | "profile";
export type WikiArticle = {
title: string;
html: string;
};
export type Puzzle = {
start: string;
target: string;
};
+253
View File
@@ -0,0 +1,253 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { fetchArticle, pickTwoArticles, prefetchArticle, POLL_INTERVAL, COUNTDOWN_DURATION } from "./wiki";
import { useTimer } from "./useTimer";
import { saveSession, clearSession } from "./session";
import type { Room } from "../app/api/rooms/route";
export function useMultiGame() {
const timer = useTimer();
const [room, setRoom] = useState<Room | null>(null);
const [playerId, setPlayerId] = useState<string | null>(null);
const clicksRef = useRef(0);
const [clicksDisplay, setClicksDisplay] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const historyRef = useRef<string[]>([]);
const [html, setHtml] = useState("");
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadingRef = useRef(false);
const timerStartedRef = useRef(false);
const [countdown, setCountdown] = useState<number | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevPhaseRef = useRef<string | null>(null);
const prevRoundRef = useRef(0);
// Article loading
async function loadArticle(t: string): Promise<string | null> {
setLoading(true); loadingRef.current = true; setLoadError(null);
const art = await fetchArticle(t);
setLoading(false); loadingRef.current = false;
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
setHtml(art.html); setTitle(art.title);
return art.title;
}
// Countdown
function startCountdown(start: number) {
if (countdownRef.current) clearInterval(countdownRef.current);
const tick = () => {
const rem = Math.ceil((COUNTDOWN_DURATION - (Date.now() - start)) / 1000);
setCountdown(rem <= 0 ? 0 : rem);
};
tick();
countdownRef.current = setInterval(tick, 200);
}
function stopCountdown() {
if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; }
setCountdown(null);
}
// Polling
function stopPolling() {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
async function poll(code: string, pid: string) {
try {
const res = await fetch(`/api/rooms/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
});
if (res.ok) setRoom((await res.json() as { room: Room }).room);
} catch { /* ignore */ }
}
function startPolling(code: string, pid: string) {
stopPolling();
pollRef.current = setInterval(() => poll(code, pid), POLL_INTERVAL);
}
// Phase sync
useEffect(() => {
if (!room) return;
const prevPhase = prevPhaseRef.current;
const prevRound = prevRoundRef.current;
prevPhaseRef.current = room.phase;
prevRoundRef.current = room.round;
if (room.phase === "countdown" && prevPhase !== "countdown") {
setHtml(""); setLoadError(null);
clicksRef.current = 0; setClicksDisplay(0);
timerStartedRef.current = false;
timer.reset();
startCountdown(room.countdownStart ?? Date.now());
}
if (room.phase === "playing" && prevPhase !== "playing") {
stopCountdown();
historyRef.current = [room.startArticle];
setHistory([room.startArticle]);
loadArticle(room.startArticle);
}
if (room.phase === "results" && prevPhase !== "results") {
timer.stop();
}
if (room.round !== prevRound && room.phase === "playing") {
loadArticle(room.startArticle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room]);
// Countdown -> playing transition
useEffect(() => {
if (!room || room.phase !== "countdown" || !playerId) return;
if (Date.now() - (room.countdownStart ?? 0) >= COUNTDOWN_DURATION) {
fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "play", playerId }),
}).then((r) => r.json()).then((d) => {
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
}).catch(() => {});
}
}, [countdown, room, playerId]);
// Navigation
const navigate = useCallback(async (t: string) => {
if (!room || !playerId || loadingRef.current || room.phase !== "playing") return;
clicksRef.current += 1; setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
const canonical = await loadArticle(t);
if (!canonical) return;
const newHistory = [...historyRef.current, canonical];
historyRef.current = newHistory;
setHistory(newHistory);
window.scrollTo({ top: 0, behavior: "smooth" });
try {
const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "navigate", playerId, article: canonical }),
});
if (res.ok) setRoom((await res.json() as { room: Room }).room);
} catch { /* on continue localement */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, playerId]);
// Room actions
async function createRoom(playerName: string): Promise<{ error?: string }> {
const res = await fetch("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerName }),
});
const data = await res.json() as { room?: Room; playerId?: string; error?: string };
if (!res.ok) return { error: data.error ?? "Erreur" };
setRoom(data.room!); setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!);
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName });
return {};
}
async function joinRoom(playerName: string, code: string): Promise<{ error?: string }> {
const res = await fetch(`/api/rooms/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "join", playerName }),
});
const data = await res.json() as { room?: Room; playerId?: string; error?: string };
if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" };
setRoom(data.room!); setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!);
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName });
return {};
}
async function startGame(): Promise<{ error?: string }> {
if (!room || !playerId) return {};
const puzzle = await pickTwoArticles();
const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "start", playerId, startArticle: puzzle.start, targetArticle: puzzle.target }),
});
const data = await res.json() as { room?: Room; error?: string };
if (!res.ok) return { error: data.error ?? "Erreur" };
prefetchArticle(puzzle.start);
setRoom(data.room!);
return {};
}
async function nextRound() {
if (!room || !playerId) return;
const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "nextRound", playerId }),
});
if (res.ok) setRoom((await res.json() as { room: Room }).room);
}
async function resetGame() {
if (!room || !playerId) return;
const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resetGame", playerId }),
});
if (res.ok) setRoom((await res.json() as { room: Room }).room);
}
function leave() {
stopPolling(); stopCountdown(); timer.stop();
setRoom(null); setPlayerId(null);
setHtml(""); setTitle("");
setHistory([]); historyRef.current = [];
clicksRef.current = 0; setClicksDisplay(0);
timerStartedRef.current = false;
clearSession();
}
// Restore session depuis sessionStorage (F5)
async function restore(code: string, pid: string): Promise<boolean> {
try {
const res = await fetch(`/api/rooms/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
});
if (!res.ok) return false;
const data = await res.json() as { room: Room };
setRoom(data.room);
setPlayerId(pid);
startPolling(code, pid);
return true;
} catch {
return false;
}
}
return {
room, playerId, html, title, loading, loadError,
history, clicks: clicksDisplay, elapsed: timer.elapsed, countdown,
createRoom, joinRoom, startGame, nextRound, resetGame, leave, navigate, restore,
retryLoad: () => title && loadArticle(title),
};
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { fetchArticle, pickTwoArticles, normalizeTitle } from "./wiki";
import { useTimer } from "./useTimer";
import { saveSession, clearSession } from "./session";
import type { Puzzle } from "./types";
export type SoloPhase = "setup" | "playing" | "won";
export function useSoloGame() {
const timer = useTimer();
const clicksRef = useRef(0);
const [clicksDisplay, setClicksDisplay] = useState(0);
const pathRef = useRef<string[]>([]);
const [history, setHistory] = useState<string[]>([]);
const timerStartedRef = useRef(false);
const gameEndedRef = useRef(false);
const loadingRef = useRef(false);
const [html, setHtml] = useState("");
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(false);
const [phase, setPhase] = useState<SoloPhase>("setup");
const [puzzle, setPuzzle] = useState<Puzzle | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
async function loadArticle(t: string): Promise<string | null> {
setLoadError(null);
setLoading(true);
loadingRef.current = true;
const art = await fetchArticle(t);
setLoading(false);
loadingRef.current = false;
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
setHtml(art.html);
setTitle(art.title);
return art.title;
}
async function start() {
setLoading(true);
clicksRef.current = 0; setClicksDisplay(0);
pathRef.current = []; setHistory([]);
timerStartedRef.current = false;
gameEndedRef.current = false;
timer.reset();
setLoadError(null);
const p = await pickTwoArticles();
setPuzzle(p);
const canonical = await loadArticle(p.start);
if (!canonical) return;
pathRef.current = [canonical];
setHistory([canonical]);
setPhase("playing");
saveSession({ screen: "solo", soloPuzzle: p, soloHistory: [canonical], soloClicks: 0 });
}
const navigate = useCallback(async (t: string) => {
if (loadingRef.current || gameEndedRef.current) return;
clicksRef.current += 1;
setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
const canonical = await loadArticle(t);
if (!canonical) return;
const newPath = [...pathRef.current, canonical];
pathRef.current = newPath;
setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" });
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) {
timer.stop();
gameEndedRef.current = true;
setPhase("won");
clearSession();
} else {
saveSession({ screen: "solo", soloPuzzle: puzzle ?? undefined, soloHistory: newPath, soloClicks: clicksRef.current });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [puzzle]);
const goBack = useCallback(async () => {
if (loadingRef.current || gameEndedRef.current || pathRef.current.length <= 1) return;
clicksRef.current += 1;
setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
const newPath = pathRef.current.slice(0, -1);
const canonical = await loadArticle(newPath[newPath.length - 1]);
if (!canonical) return;
pathRef.current = newPath;
setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function reset() {
timer.reset();
clicksRef.current = 0; setClicksDisplay(0);
pathRef.current = []; setHistory([]);
timerStartedRef.current = false;
gameEndedRef.current = false;
setHtml(""); setTitle("");
setPuzzle(null);
setPhase("setup");
setLoadError(null);
clearSession();
}
// Expose une fonction pour restaurer une session sauvegardée
async function restore(savedPuzzle: Puzzle, savedHistory: string[], savedClicks: number) {
setPuzzle(savedPuzzle);
clicksRef.current = savedClicks; setClicksDisplay(savedClicks);
const lastTitle = savedHistory[savedHistory.length - 1];
const canonical = await loadArticle(lastTitle);
if (!canonical) return false;
pathRef.current = savedHistory;
setHistory(savedHistory);
timerStartedRef.current = false;
gameEndedRef.current = false;
setPhase("playing");
return true;
}
return {
phase, puzzle, html, title, loading, loadError, history,
clicks: clicksDisplay, elapsed: timer.elapsed,
canGoBack: pathRef.current.length > 1,
start, navigate, goBack, reset, restore,
retryLoad: () => title && loadArticle(title),
};
}
export function useSoloKeyboard(
active: boolean,
goBack: () => void,
) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (
e.key === "Backspace" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
e.preventDefault();
goBack();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, goBack]);
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback, useRef } from "react";
export function useTimer() {
const [elapsed, setElapsed] = useState(0);
const startTimeRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const startRef = useRef(() => {
startTimeRef.current = performance.now();
function tick() {
if (startTimeRef.current !== null) {
setElapsed((performance.now() - startTimeRef.current) / 1000);
rafRef.current = requestAnimationFrame(tick);
}
}
rafRef.current = requestAnimationFrame(tick);
});
const stopRef = useRef((): number => {
let final = 0;
if (startTimeRef.current !== null) {
final = (performance.now() - startTimeRef.current) / 1000;
setElapsed(final);
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
startTimeRef.current = null;
return final;
});
const resetRef = useRef(() => {
stopRef.current();
setElapsed(0);
});
const start = useCallback(() => startRef.current(), []);
const stop = useCallback(() => stopRef.current(), []);
const reset = useCallback(() => resetRef.current(), []);
useEffect(() => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }, []);
return { elapsed, start, stop, reset };
}
+113
View File
@@ -0,0 +1,113 @@
import type { WikiArticle, Puzzle } from "./types";
import { getFallbackPuzzle } from "./puzzles";
const WIKI_API_BASE = "https://fr.wikipedia.org/w/api.php";
const MIN_ARTICLE_BYTES = 10000;
const BAD_TITLE_PREFIXES = ["Liste de", "Liste des", "Index de", "Portail:"];
const BAD_TITLE_SUFFIXES = ["(homonymie)", "(disambiguation)"];
// Cache de promesses module-level
const articleCache = new Map<string, Promise<WikiArticle | null>>();
function doFetchArticle(title: string): Promise<WikiArticle | null> {
const params = new URLSearchParams({
action: "parse",
page: title,
format: "json",
origin: "*",
prop: "text|displaytitle",
disableeditsection: "1",
redirects: "1",
});
return fetch(`${WIKI_API_BASE}?${params}`)
.then((res) => {
if (!res.ok) throw new Error("Erreur reseau");
return res.json();
})
.then((data): WikiArticle => {
if (data.error) throw new Error(data.error.info ?? "Article introuvable");
return {
html: data.parse.text["*"] as string,
title: data.parse.title as string,
};
})
.catch((err) => {
articleCache.delete(title);
throw err;
});
}
function getCachedArticle(title: string): Promise<WikiArticle | null> {
if (!articleCache.has(title)) {
articleCache.set(title, doFetchArticle(title));
}
return articleCache.get(title)!;
}
export function prefetchArticle(title: string): void {
getCachedArticle(title).catch(() => {});
}
export async function fetchArticle(title: string): Promise<WikiArticle | null> {
try {
return await getCachedArticle(title);
} catch {
return null;
}
}
interface WikiPageInfo {
title: string;
length: number;
}
function isGoodArticle(page: WikiPageInfo): boolean {
const t = page.title;
return (
page.length >= MIN_ARTICLE_BYTES &&
!BAD_TITLE_PREFIXES.some((p) => t.startsWith(p)) &&
!BAD_TITLE_SUFFIXES.some((s) => t.endsWith(s))
);
}
async function fetchRandomCandidates(): Promise<string[]> {
const params = new URLSearchParams({
action: "query",
generator: "random",
grnnamespace: "0",
grnlimit: "50",
grnfilterredir: "nonredirects",
prop: "info",
format: "json",
origin: "*",
});
const res = await fetch(`${WIKI_API_BASE}?${params}`);
if (!res.ok) throw new Error("Erreur reseau");
const data = await res.json() as { query: { pages: Record<string, WikiPageInfo> } };
return Object.values(data.query.pages)
.filter(isGoodArticle)
.map((p) => p.title);
}
export async function pickTwoArticles(): Promise<Puzzle> {
try {
const collected: string[] = [];
for (let i = 0; i < 3; i++) {
const batch = await fetchRandomCandidates();
for (const title of batch) {
if (!collected.includes(title)) collected.push(title);
if (collected.length >= 2) return { start: collected[0], target: collected[1] };
}
}
} catch {
// Fallback si l'API est indisponible
}
return getFallbackPuzzle();
}
export function normalizeTitle(s: string): string {
return decodeURIComponent(s).replace(/_/g, " ").toLowerCase().trim();
}
export const POLL_INTERVAL = 2000;
export const COUNTDOWN_DURATION = 3000;
+10 -1
View File
@@ -6,20 +6,29 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"postinstall": "npx node-gyp rebuild --directory node_modules/.pnpm/better-sqlite3@12.8.0/node_modules/better-sqlite3 2>/dev/null || true"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^7.7.0",
"bcryptjs": "^3.0.3",
"next": "16.2.3", "next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@prisma/adapter-better-sqlite3": "^7.7.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"better-sqlite3": "12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.3", "eslint-config-next": "16.2.3",
"prisma": "7",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
+4021 -8
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
export default {
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: "file:./wikirace.db",
},
};
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Game" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"mode" TEXT NOT NULL,
"startArticle" TEXT NOT NULL,
"targetArticle" TEXT NOT NULL,
"path" TEXT NOT NULL,
"clicks" INTEGER NOT NULL,
"timeSeconds" REAL NOT NULL,
"won" BOOLEAN NOT NULL DEFAULT true,
"playedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Game_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "Game_userId_idx" ON "Game"("userId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+35
View File
@@ -0,0 +1,35 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
name String
email String @unique
password String
createdAt DateTime @default(now())
games Game[]
}
model Game {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mode String // "solo" | "multi"
startArticle String
targetArticle String
path String // JSON array de titres
clicks Int
timeSeconds Float
won Boolean @default(true)
playedAt DateTime @default(now())
@@index([userId])
}
BIN
View File
Binary file not shown.