feat: add user accounts, authentication, and game history with Prisma 7
- Implement user registration and login with NextAuth v5 (email/password, JWT) - Add authentication modal in UI with login/register tabs - Create user profile screen showing game statistics and history - Integrate Prisma 7 ORM with SQLite database for data persistence - Store game results (mode, path, clicks, time) in database - Auto-save completed games only when user is authenticated - Separate business logic into reusable hooks (useSoloGame, useMultiGame) - Organize UI into composable screen components (HomeScreen, SoloScreen, ProfileScreen, etc) - Add session persistence across F5 refresh for solo and multiplayer - Style auth modal, account button, and profile stats dashboard
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "../../../../auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../../auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
// POST /api/games — sauvegarder une partie
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non connecté" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { mode, startArticle, targetArticle, path, clicks, timeSeconds, won } =
|
||||
await req.json() as {
|
||||
mode: string;
|
||||
startArticle: string;
|
||||
targetArticle: string;
|
||||
path: string[];
|
||||
clicks: number;
|
||||
timeSeconds: number;
|
||||
won: boolean;
|
||||
};
|
||||
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
mode,
|
||||
startArticle,
|
||||
targetArticle,
|
||||
path: JSON.stringify(path),
|
||||
clicks,
|
||||
timeSeconds,
|
||||
won,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: game.id });
|
||||
}
|
||||
|
||||
// GET /api/games — historique de l'utilisateur connecté
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non connecté" }, { status: 401 });
|
||||
}
|
||||
|
||||
const games = await prisma.game.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { playedAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
games.map((g) => ({
|
||||
...g,
|
||||
path: JSON.parse(g.path) as string[],
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { name, email, password } = await req.json() as { name?: string; email?: string; password?: string };
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !password || password.length < 6) {
|
||||
return NextResponse.json({ error: "Champs invalides (mot de passe min. 6 caractères)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Cet email est déjà utilisé" }, { status: 409 });
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: { name: name.trim(), email: email.toLowerCase(), password: hashed },
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: user.id, name: user.name, email: user.email }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Route handler pour les actions sur une room specifique
|
||||
// PATCH /api/rooms/[code] - actions: join, heartbeat, start, navigate, leave, nextRound
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import type { Room, Player } from "../route";
|
||||
|
||||
// Acces au singleton
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __wikirooms: Map<string, Room> | undefined;
|
||||
}
|
||||
|
||||
function getRooms(): Map<string, Room> {
|
||||
if (!global.__wikirooms) {
|
||||
global.__wikirooms = new Map();
|
||||
}
|
||||
return global.__wikirooms;
|
||||
}
|
||||
|
||||
function generatePlayerId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
// Timeout joueur inactif : 15s
|
||||
const PLAYER_TIMEOUT_MS = 15_000;
|
||||
|
||||
function prunePlayers(room: Room) {
|
||||
const now = Date.now();
|
||||
room.players = room.players.filter(
|
||||
(p) => now - p.lastSeen < PLAYER_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
// PATCH /api/rooms/[code]
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
const { code } = await params;
|
||||
const rooms = getRooms();
|
||||
const room = rooms.get(code.toUpperCase());
|
||||
|
||||
if (!room) {
|
||||
return Response.json({ error: "Room introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, playerId, playerName, article, startArticle, targetArticle } =
|
||||
body as {
|
||||
action: string;
|
||||
playerId?: string;
|
||||
playerName?: string;
|
||||
article?: string;
|
||||
startArticle?: string;
|
||||
targetArticle?: string;
|
||||
};
|
||||
|
||||
// Nettoyer les joueurs inactifs avant chaque action
|
||||
prunePlayers(room);
|
||||
|
||||
switch (action) {
|
||||
// Rejoindre
|
||||
case "join": {
|
||||
if (!playerName || typeof playerName !== "string" || playerName.trim() === "") {
|
||||
return Response.json({ error: "Pseudo invalide" }, { status: 400 });
|
||||
}
|
||||
if (room.players.length >= 8) {
|
||||
return Response.json({ error: "Salle pleine (8 joueurs max)" }, { status: 409 });
|
||||
}
|
||||
if (room.phase !== "waiting" && room.phase !== "results") {
|
||||
return Response.json({ error: "Partie en cours, attends la prochaine manche" }, { status: 409 });
|
||||
}
|
||||
|
||||
const newId = generatePlayerId();
|
||||
const player: Player = {
|
||||
id: newId,
|
||||
name: playerName.trim().slice(0, 20),
|
||||
score: 0,
|
||||
currentArticle: "",
|
||||
hasWon: false,
|
||||
isHost: false,
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
room.players.push(player);
|
||||
return Response.json({ room, playerId: newId });
|
||||
}
|
||||
|
||||
// Heartbeat (polling)
|
||||
case "heartbeat": {
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
if (player) {
|
||||
player.lastSeen = Date.now();
|
||||
}
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
// Demarrer la partie
|
||||
case "start": {
|
||||
const host = room.players.find((p) => p.id === playerId);
|
||||
if (!host?.isHost) {
|
||||
return Response.json({ error: "Seul l'hote peut demarrer" }, { status: 403 });
|
||||
}
|
||||
if (room.players.length < 1) {
|
||||
return Response.json({ error: "Pas assez de joueurs" }, { status: 400 });
|
||||
}
|
||||
if (!startArticle || !targetArticle) {
|
||||
return Response.json({ error: "Articles manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Reset scores si c'est la toute premiere manche
|
||||
if (room.round === 0) {
|
||||
for (const p of room.players) {
|
||||
p.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
room.round += 1;
|
||||
room.startArticle = startArticle;
|
||||
room.targetArticle = targetArticle;
|
||||
room.roundWinner = null;
|
||||
room.phase = "countdown";
|
||||
room.countdownStart = Date.now();
|
||||
room.roundStart = null;
|
||||
|
||||
// Reset etat joueurs pour cette manche
|
||||
for (const p of room.players) {
|
||||
p.currentArticle = startArticle;
|
||||
p.hasWon = false;
|
||||
}
|
||||
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
// Passer en playing (apres countdown)
|
||||
case "play": {
|
||||
if (room.phase !== "countdown") {
|
||||
return Response.json({ room });
|
||||
}
|
||||
// On laisse les clients gerer le timing - le 1er qui appelle play apres 3s active
|
||||
const elapsed = Date.now() - (room.countdownStart ?? 0);
|
||||
if (elapsed >= 3000) {
|
||||
room.phase = "playing";
|
||||
room.roundStart = Date.now();
|
||||
}
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
// Navigation vers un article
|
||||
case "navigate": {
|
||||
if (room.phase !== "playing") {
|
||||
return Response.json({ room });
|
||||
}
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
if (!player) {
|
||||
return Response.json({ error: "Joueur inconnu" }, { status: 404 });
|
||||
}
|
||||
|
||||
player.currentArticle = article ?? "";
|
||||
player.lastSeen = Date.now();
|
||||
|
||||
// Verifier si le joueur a atteint la cible
|
||||
const normalize = (s: string) =>
|
||||
decodeURIComponent(s).replace(/_/g, " ").toLowerCase().trim();
|
||||
|
||||
if (
|
||||
!player.hasWon &&
|
||||
normalize(player.currentArticle) === normalize(room.targetArticle)
|
||||
) {
|
||||
player.hasWon = true;
|
||||
|
||||
// 1er joueur a gagner = +10 points
|
||||
const alreadyWon = room.players.some(
|
||||
(p) => p.hasWon && p.id !== player.id
|
||||
);
|
||||
if (!alreadyWon) {
|
||||
player.score += 10;
|
||||
room.roundWinner = player.id;
|
||||
room.phase = "results";
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
// Manche suivante / rejouer
|
||||
case "nextRound": {
|
||||
const host = room.players.find((p) => p.id === playerId);
|
||||
if (!host?.isHost) {
|
||||
return Response.json({ error: "Seul l'hote peut continuer" }, { status: 403 });
|
||||
}
|
||||
room.phase = "waiting";
|
||||
room.roundWinner = null;
|
||||
room.countdownStart = null;
|
||||
room.roundStart = null;
|
||||
for (const p of room.players) {
|
||||
p.hasWon = false;
|
||||
p.currentArticle = "";
|
||||
}
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
// Nouvelle partie (reset total)
|
||||
case "resetGame": {
|
||||
const host = room.players.find((p) => p.id === playerId);
|
||||
if (!host?.isHost) {
|
||||
return Response.json({ error: "Seul l'hote peut reinitialiser" }, { status: 403 });
|
||||
}
|
||||
room.phase = "waiting";
|
||||
room.round = 0;
|
||||
room.roundWinner = null;
|
||||
room.countdownStart = null;
|
||||
room.roundStart = null;
|
||||
for (const p of room.players) {
|
||||
p.score = 0;
|
||||
p.hasWon = false;
|
||||
p.currentArticle = "";
|
||||
}
|
||||
return Response.json({ room });
|
||||
}
|
||||
|
||||
default:
|
||||
return Response.json({ error: "Action inconnue" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Route handler: POST /api/rooms - creer une room
|
||||
// GET /api/rooms?code=XXXX - recuperer l'etat d'une room
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Types
|
||||
|
||||
export type Player = {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
currentArticle: string;
|
||||
hasWon: boolean;
|
||||
isHost: boolean;
|
||||
lastSeen: number; // timestamp ms
|
||||
};
|
||||
|
||||
export type Room = {
|
||||
code: string;
|
||||
players: Player[];
|
||||
phase: "waiting" | "countdown" | "playing" | "results";
|
||||
round: number;
|
||||
totalRounds: number;
|
||||
startArticle: string;
|
||||
targetArticle: string;
|
||||
roundWinner: string | null; // player id
|
||||
countdownStart: number | null; // timestamp ms
|
||||
roundStart: number | null; // timestamp ms
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
// Stockage en memoire (singleton Node.js)
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __wikirooms: Map<string, Room> | undefined;
|
||||
}
|
||||
|
||||
function getRooms(): Map<string, Room> {
|
||||
if (!global.__wikirooms) {
|
||||
global.__wikirooms = new Map();
|
||||
}
|
||||
return global.__wikirooms;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
let code = "";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function generatePlayerId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
// Nettoie les rooms inactives depuis plus de 2h
|
||||
function pruneOldRooms(rooms: Map<string, Room>) {
|
||||
const now = Date.now();
|
||||
for (const [code, room] of rooms) {
|
||||
if (now - room.createdAt > 2 * 60 * 60 * 1000) {
|
||||
rooms.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers
|
||||
|
||||
// POST /api/rooms
|
||||
// Body: { playerName: string }
|
||||
// Response: { room: Room, playerId: string }
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { playerName } = body as { playerName: string };
|
||||
|
||||
if (!playerName || typeof playerName !== "string" || playerName.trim() === "") {
|
||||
return Response.json({ error: "Pseudo invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rooms = getRooms();
|
||||
pruneOldRooms(rooms);
|
||||
|
||||
// Generer un code unique
|
||||
let code = generateCode();
|
||||
let attempts = 0;
|
||||
while (rooms.has(code) && attempts < 20) {
|
||||
code = generateCode();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const playerId = generatePlayerId();
|
||||
|
||||
const room: Room = {
|
||||
code,
|
||||
players: [
|
||||
{
|
||||
id: playerId,
|
||||
name: playerName.trim().slice(0, 20),
|
||||
score: 0,
|
||||
currentArticle: "",
|
||||
hasWon: false,
|
||||
isHost: true,
|
||||
lastSeen: Date.now(),
|
||||
},
|
||||
],
|
||||
phase: "waiting",
|
||||
round: 0,
|
||||
totalRounds: 3,
|
||||
startArticle: "",
|
||||
targetArticle: "",
|
||||
roundWinner: null,
|
||||
countdownStart: null,
|
||||
roundStart: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
rooms.set(code, room);
|
||||
|
||||
return Response.json({ room, playerId });
|
||||
}
|
||||
|
||||
// GET /api/rooms?code=XXXX
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
if (!code) {
|
||||
return Response.json({ error: "Code manquant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rooms = getRooms();
|
||||
const room = rooms.get(code.toUpperCase());
|
||||
|
||||
if (!room) {
|
||||
return Response.json({ error: "Room introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ room });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const FORBIDDEN_NAMESPACES = [
|
||||
"Fichier:", "File:", "Wikipedia:", "Aide:", "Help:", "Categorie:", "Category:",
|
||||
"Discussion:", "Talk:", "Utilisateur:", "User:", "Special:", "Sp\u00e9cial:",
|
||||
"Portail:", "Portal:", "Mod\u00e8le:", "Template:", "Projet:", "WP:",
|
||||
];
|
||||
|
||||
const REMOVED_SECTION_IDS = [
|
||||
"Liens_externes", "R\u00e9f\u00e9rences", "Notes", "Bibliographie",
|
||||
"Voir_aussi", "Notes_et_r\u00e9f\u00e9rences", "Sources",
|
||||
"Annexes", "Articles_connexes",
|
||||
];
|
||||
|
||||
function cleanWikiHtml(container: HTMLElement): void {
|
||||
container.querySelectorAll(".mw-editsection").forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".ambox, .tmbox, .cmbox, .ombox, .fmbox, .hatnote, .bandeau-container, .bandeau"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(".audio, .audiolink, audio, video").forEach((el) => el.remove());
|
||||
container.querySelectorAll(".gallery").forEach((el) => el.remove());
|
||||
container.querySelectorAll("#toc, .toc").forEach((el) => el.remove());
|
||||
|
||||
container.querySelectorAll("div, nav").forEach((el) => {
|
||||
const links = el.querySelectorAll("a");
|
||||
if (links.length > 3) {
|
||||
const anchorOnly = Array.from(links).every((a) => {
|
||||
const href = a.getAttribute("href") ?? "";
|
||||
return href.startsWith("#") || href.includes("#");
|
||||
});
|
||||
if (anchorOnly) el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelectorAll("h2, h3").forEach((heading) => {
|
||||
const span = heading.querySelector("span[id]");
|
||||
if (!span) return;
|
||||
const id = span.getAttribute("id") ?? "";
|
||||
if (REMOVED_SECTION_IDS.some((s) => id === s || id.startsWith(s + "_"))) {
|
||||
let sibling: Element | null = heading;
|
||||
while (sibling) {
|
||||
const next: Element | null = sibling.nextElementSibling;
|
||||
sibling.remove();
|
||||
sibling = next;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function ArticleView({
|
||||
html,
|
||||
onNavigate,
|
||||
disabled = false,
|
||||
}: {
|
||||
html: string;
|
||||
onNavigate: (title: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onNavigateRef = useRef(onNavigate);
|
||||
const disabledRef = useRef(disabled);
|
||||
|
||||
useEffect(() => { onNavigateRef.current = onNavigate; });
|
||||
useEffect(() => { disabledRef.current = disabled; });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !html) return;
|
||||
|
||||
container.innerHTML = html;
|
||||
cleanWikiHtml(container);
|
||||
|
||||
container.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']").forEach((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
const path = href.replace("/wiki/", "");
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(path); } catch { decoded = path; }
|
||||
const title = decoded.replace(/_/g, " ");
|
||||
|
||||
if (FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) || title.includes("#")) {
|
||||
link.removeAttribute("href");
|
||||
return;
|
||||
}
|
||||
link.setAttribute("data-wiki-title", title);
|
||||
link.removeAttribute("href");
|
||||
link.classList.add("wiki-link");
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
|
||||
link.removeAttribute("href");
|
||||
});
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
const target = (e.target as HTMLElement).closest("[data-wiki-title]") as HTMLElement | null;
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
const title = target.getAttribute("data-wiki-title");
|
||||
if (title) onNavigateRef.current(title);
|
||||
};
|
||||
|
||||
container.addEventListener("click", handleClick);
|
||||
return () => container.removeEventListener("click", handleClick);
|
||||
}, [html]);
|
||||
|
||||
return <div ref={containerRef} className="article-content" />;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (mode === "register") {
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
const data = await res.json() as { error?: string };
|
||||
if (!res.ok) { setError(data.error ?? "Erreur"); return; }
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email, password, redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) { setError("Email ou mot de passe incorrect"); return; }
|
||||
onSuccess();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-tabs">
|
||||
<button
|
||||
className={`modal-tab ${mode === "login" ? "active" : ""}`}
|
||||
onClick={() => { setMode("login"); setError(null); }}
|
||||
>
|
||||
Connexion
|
||||
</button>
|
||||
<button
|
||||
className={`modal-tab ${mode === "register" ? "active" : ""}`}
|
||||
onClick={() => { setMode("register"); setError(null); }}
|
||||
>
|
||||
Inscription
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="modal-form" onSubmit={handleSubmit}>
|
||||
{mode === "register" && (
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Pseudo"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={30}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Mot de passe"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 0 }}>{error}</div>}
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||
{loading ? "Chargement..." : mode === "login" ? "Se connecter" : "Créer un compte"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button className="modal-close" onClick={onClose} aria-label="Fermer">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
export function Breadcrumbs({ history, endRef }: { history: string[]; endRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
return (
|
||||
<div className="breadcrumb-trail">
|
||||
{history.map((title, i) => (
|
||||
<span key={i} className="breadcrumb-item">
|
||||
{i > 0 && <span className="breadcrumb-sep">›</span>}
|
||||
<span className={i === history.length - 1 ? "breadcrumb-current" : "breadcrumb-past"}>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBreadcrumbScroll() {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
return endRef;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ArticleView } from "./ArticleView";
|
||||
import { Breadcrumbs } from "./Breadcrumbs";
|
||||
import type { Room } from "../api/rooms/route";
|
||||
|
||||
type GameScreenProps = {
|
||||
room: Room;
|
||||
playerId: string;
|
||||
html: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
history: string[];
|
||||
clicks: number;
|
||||
elapsed: string;
|
||||
countdown: number | null;
|
||||
onNavigate: (title: string) => void;
|
||||
onRetry: () => void;
|
||||
onNextRound: () => void;
|
||||
onResetGame: () => void;
|
||||
};
|
||||
|
||||
export function GameScreen({
|
||||
room, playerId, html, title, loading, loadError, history, clicks, elapsed,
|
||||
countdown, onNavigate, onRetry, onNextRound, onResetGame,
|
||||
}: GameScreenProps) {
|
||||
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
|
||||
const myPlayer = room.players.find((p) => p.id === playerId);
|
||||
const isHost = myPlayer?.isHost ?? false;
|
||||
const sortedPlayers = [...room.players].sort((a, b) => b.score - a.score);
|
||||
const winner = room.roundWinner ? room.players.find((p) => p.id === room.roundWinner) : null;
|
||||
|
||||
useEffect(() => {
|
||||
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
|
||||
}, [history]);
|
||||
|
||||
if (room.phase === "results") {
|
||||
return (
|
||||
<div className="screen results-screen">
|
||||
<div className="results-content">
|
||||
<div className="results-winner-banner">
|
||||
{winner ? (
|
||||
<>
|
||||
<div className="results-winner-emoji">Victoire !</div>
|
||||
<div className="results-winner-name">{winner.name} a gagne la manche !</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="results-winner-name">Manche terminee !</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="results-path">
|
||||
<span className="path-start">{room.startArticle}</span>
|
||||
<span className="path-arrow"> → </span>
|
||||
<span className="path-end">{room.targetArticle}</span>
|
||||
</div>
|
||||
<div className="results-scoreboard">
|
||||
<h3 className="results-title">Classement</h3>
|
||||
<ul className="scoreboard-list">
|
||||
{sortedPlayers.map((p, i) => (
|
||||
<li key={p.id} className={`scoreboard-item ${p.id === playerId ? "me" : ""}`}>
|
||||
<span className="scoreboard-rank">#{i + 1}</span>
|
||||
<span className="scoreboard-name">{p.name}</span>
|
||||
{p.hasWon && <span className="player-badge winner">Gagnant</span>}
|
||||
<span className="scoreboard-score">{p.score} pts</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{isHost ? (
|
||||
<div className="results-actions">
|
||||
<button className="btn btn-primary" onClick={onNextRound}>Manche suivante</button>
|
||||
<button className="btn btn-ghost" onClick={onResetGame}>Nouvelle partie</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="lobby-waiting">En attente de l'hote...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (room.phase === "countdown") {
|
||||
return (
|
||||
<div className="screen countdown-screen">
|
||||
<div className="countdown-content">
|
||||
<div className="countdown-path">
|
||||
<div className="countdown-article">
|
||||
<span className="countdown-label">Depart</span>
|
||||
<span className="countdown-article-name">{room.startArticle}</span>
|
||||
</div>
|
||||
<div className="countdown-arrow">→</div>
|
||||
<div className="countdown-article">
|
||||
<span className="countdown-label">Cible</span>
|
||||
<span className="countdown-article-name highlight">{room.targetArticle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="countdown-number">
|
||||
{countdown !== null && countdown > 0 ? countdown : "Partez !"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screen game-screen">
|
||||
<div className="game-topbar">
|
||||
<div className="topbar-trail-zone">
|
||||
<div className="topbar-target-row">
|
||||
<span className="topbar-label">CIBLE</span>
|
||||
<span className="topbar-target-name">{room.targetArticle}</span>
|
||||
</div>
|
||||
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
|
||||
</div>
|
||||
<div className="topbar-stats">
|
||||
<span className="stat">{elapsed}</span>
|
||||
<span className="stat">{clicks} clics</span>
|
||||
<span className="stat">{myPlayer?.score ?? 0} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-layout">
|
||||
<div className="game-main">
|
||||
{loading && (
|
||||
<div className="article-loading"><div className="loading-spinner" /> Chargement...</div>
|
||||
)}
|
||||
{loadError && (
|
||||
<div className="article-error">
|
||||
<p>{loadError}</p>
|
||||
<button className="btn btn-secondary" onClick={onRetry}>Reessayer</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !loadError && html && (
|
||||
<div className="article-container">
|
||||
<h1 className="article-title">{title}</h1>
|
||||
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="game-sidebar">
|
||||
<h4 className="sidebar-title">Joueurs</h4>
|
||||
<ul className="sidebar-players">
|
||||
{sortedPlayers.map((p) => (
|
||||
<li key={p.id} className={`sidebar-player ${p.id === playerId ? "me" : ""}`}>
|
||||
<span className="sidebar-player-name">{p.name}</span>
|
||||
<span className="sidebar-player-score">{p.score} pts</span>
|
||||
{p.hasWon && <span className="sidebar-player-won">Gagne !</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
type HomeScreenProps = {
|
||||
playerName: string;
|
||||
setPlayerName: (v: string) => void;
|
||||
joinCode: string;
|
||||
setJoinCode: (v: string) => void;
|
||||
error: string | null;
|
||||
setError: (v: string | null) => void;
|
||||
loading: boolean;
|
||||
onCreateRoom: () => void;
|
||||
onJoinRoom: () => void;
|
||||
onSolo: () => void;
|
||||
session: Session | null;
|
||||
onShowAuth: () => void;
|
||||
onShowProfile: () => void;
|
||||
};
|
||||
|
||||
export function HomeScreen({
|
||||
playerName, setPlayerName, joinCode, setJoinCode,
|
||||
error, setError, loading, onCreateRoom, onJoinRoom, onSolo,
|
||||
session, onShowAuth, onShowProfile,
|
||||
}: HomeScreenProps) {
|
||||
return (
|
||||
<div className="screen home-screen">
|
||||
<div className="home-topbar">
|
||||
{session?.user ? (
|
||||
<button className="btn-account" onClick={onShowProfile}>
|
||||
<span className="account-avatar">{session.user.name?.[0]?.toUpperCase() ?? "?"}</span>
|
||||
<span className="account-name">{session.user.name}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onShowAuth}>
|
||||
Connexion / Inscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="home-hero">
|
||||
<div className="home-logo">
|
||||
<span className="logo-wiki">Wiki</span>
|
||||
<span className="logo-race">Rush</span>
|
||||
</div>
|
||||
<p className="home-subtitle">
|
||||
Navigue entre les articles Wikipedia pour atteindre la cible en premier !
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="error-close">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="home-form">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Ton pseudo"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => e.key === "Enter" && onCreateRoom()}
|
||||
/>
|
||||
|
||||
<div className="home-actions">
|
||||
<div className="home-section">
|
||||
<h3>Multijoueur</h3>
|
||||
<button className="btn btn-primary" onClick={onCreateRoom} disabled={loading}>
|
||||
{loading ? "Creation..." : "Creer une partie"}
|
||||
</button>
|
||||
<div className="join-row">
|
||||
<input
|
||||
className="input input-code"
|
||||
type="text"
|
||||
placeholder="Code (ex: WIKI)"
|
||||
value={joinCode}
|
||||
onChange={(e) => setJoinCode(e.target.value.toUpperCase().slice(0, 4))}
|
||||
maxLength={4}
|
||||
onKeyDown={(e) => e.key === "Enter" && onJoinRoom()}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={onJoinRoom} disabled={loading}>
|
||||
Rejoindre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="home-divider">ou</div>
|
||||
|
||||
<div className="home-section">
|
||||
<h3>Solo</h3>
|
||||
<button className="btn btn-ghost" onClick={onSolo}>
|
||||
Jouer en solo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { Room } from "../api/rooms/route";
|
||||
|
||||
type LobbyScreenProps = {
|
||||
room: Room;
|
||||
playerId: string;
|
||||
error: string | null;
|
||||
setError: (v: string | null) => void;
|
||||
loading: boolean;
|
||||
onLeave: () => void;
|
||||
onStart: () => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export function LobbyScreen({
|
||||
room, playerId, error, setError, loading, onLeave, onStart, onReset,
|
||||
}: LobbyScreenProps) {
|
||||
const isHost = room.players.find((p) => p.id === playerId)?.isHost ?? false;
|
||||
|
||||
return (
|
||||
<div className="screen lobby-screen">
|
||||
<button className="btn btn-ghost btn-back" onClick={onLeave}>Quitter</button>
|
||||
|
||||
<div className="lobby-code-block">
|
||||
<span className="lobby-code-label">Code de la salle</span>
|
||||
<span className="lobby-code">{room.code}</span>
|
||||
<span className="lobby-code-hint">Partage ce code avec tes amis !</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="error-close">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lobby-players">
|
||||
<h3 className="lobby-section-title">Joueurs ({room.players.length}/8)</h3>
|
||||
<ul className="player-list">
|
||||
{room.players.map((p) => (
|
||||
<li key={p.id} className={`player-item ${p.id === playerId ? "me" : ""}`}>
|
||||
<span className="player-name">{p.name}</span>
|
||||
{p.isHost && <span className="player-badge host">Hote</span>}
|
||||
{p.id === playerId && <span className="player-badge you">Toi</span>}
|
||||
<span className="player-score">{p.score} pts</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{room.round > 0 && <div className="lobby-round-info">Manche {room.round} terminee</div>}
|
||||
|
||||
{isHost ? (
|
||||
<div className="lobby-host-actions">
|
||||
<button className="btn btn-primary" onClick={onStart} disabled={loading}>
|
||||
{loading ? "Preparation..." : room.round === 0 ? "Demarrer la partie" : "Manche suivante"}
|
||||
</button>
|
||||
{room.round > 0 && (
|
||||
<button className="btn btn-ghost" onClick={onReset}>Nouvelle partie (reset scores)</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lobby-waiting">En attente que l'hote demarre...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
type Game = {
|
||||
id: string;
|
||||
mode: string;
|
||||
startArticle: string;
|
||||
targetArticle: string;
|
||||
path: string[];
|
||||
clicks: number;
|
||||
timeSeconds: number;
|
||||
won: boolean;
|
||||
playedAt: string;
|
||||
};
|
||||
|
||||
type Stats = {
|
||||
total: number;
|
||||
won: number;
|
||||
avgClicks: number;
|
||||
avgTime: number;
|
||||
bestClicks: number;
|
||||
bestTime: number;
|
||||
};
|
||||
|
||||
function fmt(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function computeStats(games: Game[]): Stats {
|
||||
const won = games.filter((g) => g.won);
|
||||
return {
|
||||
total: games.length,
|
||||
won: won.length,
|
||||
avgClicks: won.length ? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length) : 0,
|
||||
avgTime: won.length ? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length : 0,
|
||||
bestClicks: won.length ? Math.min(...won.map((g) => g.clicks)) : 0,
|
||||
bestTime: won.length ? Math.min(...won.map((g) => g.timeSeconds)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function ProfileScreen({
|
||||
userName,
|
||||
onBack,
|
||||
}: {
|
||||
userName: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "solo" | "multi">("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/games")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setGames(data as Game[]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = filter === "all" ? games : games.filter((g) => g.mode === filter);
|
||||
const stats = computeStats(filtered);
|
||||
|
||||
return (
|
||||
<div className="screen profile-screen">
|
||||
<div className="profile-header">
|
||||
<button className="btn btn-ghost btn-back" onClick={onBack}>← Retour</button>
|
||||
<div className="profile-title">
|
||||
<span className="profile-avatar">{userName[0].toUpperCase()}</span>
|
||||
<h2 className="profile-name">{userName}</h2>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={() => signOut({ redirect: false }).then(onBack)}>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="profile-stats-grid">
|
||||
<div className="stat-card">
|
||||
<span className="stat-card-value">{stats.total}</span>
|
||||
<span className="stat-card-label">Parties</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-card-value">{stats.won}</span>
|
||||
<span className="stat-card-label">Victoires</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-card-value">{stats.avgClicks > 0 ? stats.avgClicks : "—"}</span>
|
||||
<span className="stat-card-label">Clics moy.</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-card-value">{stats.avgTime > 0 ? fmt(stats.avgTime) : "—"}</span>
|
||||
<span className="stat-card-label">Temps moy.</span>
|
||||
</div>
|
||||
<div className="stat-card accent">
|
||||
<span className="stat-card-value">{stats.bestClicks > 0 ? stats.bestClicks : "—"}</span>
|
||||
<span className="stat-card-label">Meilleur clics</span>
|
||||
</div>
|
||||
<div className="stat-card accent">
|
||||
<span className="stat-card-value">{stats.bestTime > 0 ? fmt(stats.bestTime) : "—"}</span>
|
||||
<span className="stat-card-label">Meilleur temps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-filters">
|
||||
{(["all", "solo", "multi"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`filter-btn ${filter === f ? "active" : ""}`}
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{f === "all" ? "Tout" : f === "solo" ? "Solo" : "Multi"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="games-list">
|
||||
{loading && <div className="article-loading"><div className="loading-spinner" /> Chargement...</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="games-empty">Aucune partie enregistrée.</p>
|
||||
)}
|
||||
{filtered.map((g) => (
|
||||
<div key={g.id} className={`game-card ${g.won ? "won" : "lost"}`}>
|
||||
<div className="game-card-top">
|
||||
<span className="game-card-mode">{g.mode === "solo" ? "Solo" : "Multi"}</span>
|
||||
<span className="game-card-date">{new Date(g.playedAt).toLocaleDateString("fr-FR")}</span>
|
||||
<span className={`game-card-result ${g.won ? "won" : "lost"}`}>{g.won ? "Victoire" : "Abandon"}</span>
|
||||
</div>
|
||||
<div className="game-card-route">
|
||||
<span className="game-card-start">{g.startArticle}</span>
|
||||
<span className="game-card-arrow">→</span>
|
||||
<span className="game-card-target">{g.targetArticle}</span>
|
||||
</div>
|
||||
{g.won && (
|
||||
<div className="game-card-stats">
|
||||
<span>{g.clicks} clics</span>
|
||||
<span>{fmt(g.timeSeconds)}</span>
|
||||
<span>{g.path.length - 1} articles parcourus</span>
|
||||
</div>
|
||||
)}
|
||||
{g.path.length > 0 && (
|
||||
<div className="game-card-path">
|
||||
{g.path.map((t, i) => (
|
||||
<span key={i} className="game-path-item">
|
||||
{i > 0 && <span className="game-path-sep">›</span>}
|
||||
<span className={i === g.path.length - 1 && g.won ? "game-path-end" : ""}>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ArticleView } from "./ArticleView";
|
||||
import { Breadcrumbs } from "./Breadcrumbs";
|
||||
import type { Puzzle } from "../../lib/types";
|
||||
|
||||
type SoloPhase = "setup" | "playing" | "won";
|
||||
|
||||
type SoloScreenProps = {
|
||||
phase: SoloPhase;
|
||||
puzzle: Puzzle | null;
|
||||
html: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
history: string[];
|
||||
clicks: number;
|
||||
elapsedDisplay: string;
|
||||
canGoBack: boolean;
|
||||
onStart: () => void;
|
||||
onNavigate: (title: string) => void;
|
||||
onBack: () => void;
|
||||
onQuit: () => void;
|
||||
onNewGame: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
function fmt(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SoloScreen({
|
||||
phase, puzzle, html, title, loading, loadError, history, clicks,
|
||||
elapsedDisplay, canGoBack, onStart, onNavigate, onBack, onQuit, onNewGame, onRetry,
|
||||
}: SoloScreenProps) {
|
||||
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<div className="screen solo-screen">
|
||||
{phase === "setup" && (
|
||||
<div className="center-content">
|
||||
<button className="btn btn-ghost btn-back" onClick={onQuit}>Retour</button>
|
||||
<h2 className="section-title">Mode Solo</h2>
|
||||
<p className="section-desc">
|
||||
Deux articles aleatoires seront choisis. Atteins l'article cible en cliquant uniquement sur les liens !
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={onStart} disabled={loading}>
|
||||
{loading ? "Preparation..." : "Lancer une partie"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "playing" && (
|
||||
<>
|
||||
<div className="game-main">
|
||||
<div className="article-container">
|
||||
{title && <h1 className="article-title">{title}</h1>}
|
||||
{loading && (
|
||||
<div className="article-loading">
|
||||
<div className="loading-spinner" />
|
||||
Chargement...
|
||||
</div>
|
||||
)}
|
||||
{loadError && (
|
||||
<div className="article-error">
|
||||
<p>{loadError}</p>
|
||||
<button className="btn btn-secondary" onClick={onRetry}>Reessayer</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !loadError && html && (
|
||||
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="solo-bottombar">
|
||||
<div className="breadcrumb-zone">
|
||||
<span className="breadcrumb-label">VOUS DEVEZ TROUVER</span>
|
||||
<span className="breadcrumb-target">{puzzle?.target}</span>
|
||||
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
|
||||
</div>
|
||||
<div className="solo-stats">
|
||||
<div className="solo-stat">
|
||||
<span className="solo-stat-label">TEMPS</span>
|
||||
<span className="solo-stat-value">{elapsedDisplay}</span>
|
||||
</div>
|
||||
<div className="solo-stat">
|
||||
<span className="solo-stat-label">CLICS</span>
|
||||
<span className="solo-stat-value">{clicks}</span>
|
||||
</div>
|
||||
{canGoBack && (
|
||||
<button className="btn btn-ghost solo-back-btn" onClick={onBack} disabled={loading}>
|
||||
← Retour <span className="solo-back-cost">+1</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost solo-quit" onClick={onQuit}>
|
||||
Quitter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "won" && (
|
||||
<div className="victory-screen">
|
||||
<div className="victory-content">
|
||||
<div className="victory-emoji">Bravo !</div>
|
||||
<h2 className="victory-title">Article atteint !</h2>
|
||||
<div className="victory-stats">
|
||||
<div className="victory-stat">
|
||||
<span className="victory-stat-value">{clicks}</span>
|
||||
<span className="victory-stat-label">clics</span>
|
||||
</div>
|
||||
<div className="victory-stat">
|
||||
<span className="victory-stat-value">{elapsedDisplay}</span>
|
||||
<span className="victory-stat-label">temps</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="victory-path-full">
|
||||
{history.map((t, i) => (
|
||||
<span key={i} className="victory-path-item">
|
||||
{i > 0 && <span className="victory-path-sep">›</span>}
|
||||
<span className={i === history.length - 1 ? "victory-path-end" : "victory-path-step"}>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="victory-actions">
|
||||
<button className="btn btn-primary" onClick={onNewGame}>Nouvelle partie</button>
|
||||
<button className="btn btn-ghost" onClick={onQuit}>Accueil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1517
-15
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./components/Providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "WikiRush - Jeu de navigation Wikipedia",
|
||||
description: "Navigue entre les articles Wikipedia pour atteindre la cible en premier !",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +25,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="fr"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col" style={{ background: "#0f0f0f", color: "#f0f0f0" }}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+238
-63
@@ -1,65 +1,240 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Screen } from "../lib/types";
|
||||
import { useSoloGame, useSoloKeyboard } from "../lib/useSoloGame";
|
||||
import { useMultiGame } from "../lib/useMultiGame";
|
||||
import { loadSession, clearSession } from "../lib/session";
|
||||
import { HomeScreen } from "./components/HomeScreen";
|
||||
import { LobbyScreen } from "./components/LobbyScreen";
|
||||
import { SoloScreen } from "./components/SoloScreen";
|
||||
import { GameScreen } from "./components/GameScreen";
|
||||
import { AuthModal } from "./components/AuthModal";
|
||||
import { ProfileScreen } from "./components/ProfileScreen";
|
||||
|
||||
function fmt(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
async function saveGame(data: {
|
||||
mode: string;
|
||||
startArticle: string;
|
||||
targetArticle: string;
|
||||
path: string[];
|
||||
clicks: number;
|
||||
timeSeconds: number;
|
||||
won: boolean;
|
||||
}) {
|
||||
try {
|
||||
await fetch("/api/games", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch { /* silencieux — pas de compte ou hors ligne */ }
|
||||
}
|
||||
|
||||
export default function WikiRush() {
|
||||
const { data: session } = useSession();
|
||||
const [screen, setScreen] = useState<Screen>("home");
|
||||
const [playerName, setPlayerName] = useState("");
|
||||
const [joinCode, setJoinCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
|
||||
const solo = useSoloGame();
|
||||
const multi = useMultiGame();
|
||||
|
||||
// Pré-remplir le pseudo avec le nom du compte connecté
|
||||
useEffect(() => {
|
||||
if (session?.user?.name && !playerName) setPlayerName(session.user.name);
|
||||
}, [session]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Restaurer la session apres F5
|
||||
useEffect(() => {
|
||||
const saved = loadSession();
|
||||
if (!saved) return;
|
||||
|
||||
if (saved.screen === "solo" && saved.soloPuzzle && saved.soloHistory?.length) {
|
||||
setScreen("solo");
|
||||
solo.restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0)
|
||||
.then((ok) => { if (!ok) { clearSession(); setScreen("home"); } });
|
||||
} else if (
|
||||
(saved.screen === "lobby" || saved.screen === "game") &&
|
||||
saved.multiRoomCode && saved.multiPlayerId
|
||||
) {
|
||||
if (saved.playerName) setPlayerName(saved.playerName);
|
||||
multi.restore(saved.multiRoomCode, saved.multiPlayerId).then((ok) => {
|
||||
if (ok) setScreen("lobby");
|
||||
else { clearSession(); setScreen("home"); }
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Bloquer le bouton retour navigateur
|
||||
useEffect(() => {
|
||||
const onPop = () => history.pushState(null, "", window.location.href);
|
||||
history.pushState(null, "", window.location.href);
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
// Backspace = retour arriere en solo
|
||||
useSoloKeyboard(screen === "solo" && solo.phase === "playing", solo.goBack);
|
||||
|
||||
// Sync ecran quand la room change de phase
|
||||
useEffect(() => {
|
||||
if (!multi.room) return;
|
||||
const { phase } = multi.room;
|
||||
queueMicrotask(() => {
|
||||
if (phase === "countdown" || phase === "playing") setScreen("game");
|
||||
});
|
||||
}, [multi.room]);
|
||||
|
||||
// Sauvegarder partie solo terminée
|
||||
useEffect(() => {
|
||||
if (solo.phase !== "won" || !solo.puzzle) return;
|
||||
saveGame({
|
||||
mode: "solo",
|
||||
startArticle: solo.puzzle.start,
|
||||
targetArticle: solo.puzzle.target,
|
||||
path: solo.history,
|
||||
clicks: solo.clicks,
|
||||
timeSeconds: solo.elapsed,
|
||||
won: true,
|
||||
});
|
||||
}, [solo.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ---- Actions home ----
|
||||
|
||||
async function handleCreateRoom() {
|
||||
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
|
||||
setLoading(true); setError(null);
|
||||
const { error: err } = await multi.createRoom(playerName.trim());
|
||||
setLoading(false);
|
||||
if (err) { setError(err); return; }
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
async function handleJoinRoom() {
|
||||
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
|
||||
if (joinCode.trim().length !== 4) { setError("Le code doit faire 4 lettres"); return; }
|
||||
setLoading(true); setError(null);
|
||||
const { error: err } = await multi.joinRoom(playerName.trim(), joinCode.trim().toUpperCase());
|
||||
setLoading(false);
|
||||
if (err) { setError(err); return; }
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
async function handleStartGame() {
|
||||
setLoading(true); setError(null);
|
||||
const { error: err } = await multi.startGame();
|
||||
setLoading(false);
|
||||
if (err) setError(err);
|
||||
}
|
||||
|
||||
async function handleNextRound() {
|
||||
await multi.nextRound();
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
async function handleResetGame() {
|
||||
await multi.resetGame();
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
multi.leave();
|
||||
setScreen("home");
|
||||
}
|
||||
|
||||
// ---- Rendu ----
|
||||
|
||||
if (screen === "profile") {
|
||||
return (
|
||||
<ProfileScreen
|
||||
userName={session?.user?.name ?? "Joueur"}
|
||||
onBack={() => setScreen("home")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "home") {
|
||||
return (
|
||||
<>
|
||||
<HomeScreen
|
||||
playerName={playerName} setPlayerName={setPlayerName}
|
||||
joinCode={joinCode} setJoinCode={setJoinCode}
|
||||
error={error} setError={setError} loading={loading}
|
||||
onCreateRoom={handleCreateRoom}
|
||||
onJoinRoom={handleJoinRoom}
|
||||
onSolo={() => { solo.reset(); setScreen("solo"); }}
|
||||
session={session}
|
||||
onShowAuth={() => setShowAuth(true)}
|
||||
onShowProfile={() => setScreen("profile")}
|
||||
/>
|
||||
{showAuth && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuth(false)}
|
||||
onSuccess={() => setShowAuth(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "solo") {
|
||||
return (
|
||||
<SoloScreen
|
||||
phase={solo.phase} puzzle={solo.puzzle}
|
||||
html={solo.html} title={solo.title}
|
||||
loading={solo.loading} loadError={solo.loadError}
|
||||
history={solo.history} clicks={solo.clicks}
|
||||
elapsedDisplay={fmt(solo.elapsed)}
|
||||
canGoBack={solo.canGoBack}
|
||||
onStart={solo.start}
|
||||
onNavigate={solo.navigate}
|
||||
onBack={solo.goBack}
|
||||
onQuit={() => { solo.reset(); setScreen("home"); }}
|
||||
onNewGame={solo.start}
|
||||
onRetry={solo.retryLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "lobby" && multi.room && multi.playerId) {
|
||||
return (
|
||||
<LobbyScreen
|
||||
room={multi.room} playerId={multi.playerId}
|
||||
error={error} setError={setError} loading={loading}
|
||||
onLeave={handleLeave}
|
||||
onStart={handleStartGame}
|
||||
onReset={handleResetGame}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "game" && multi.room && multi.playerId) {
|
||||
return (
|
||||
<GameScreen
|
||||
room={multi.room} playerId={multi.playerId}
|
||||
html={multi.html} title={multi.title}
|
||||
loading={multi.loading} loadError={multi.loadError}
|
||||
history={multi.history} clicks={multi.clicks}
|
||||
elapsed={fmt(multi.elapsed)}
|
||||
countdown={multi.countdown}
|
||||
onNavigate={multi.navigate}
|
||||
onRetry={multi.retryLoad}
|
||||
onNextRound={handleNextRound}
|
||||
onResetGame={handleResetGame}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -6,20 +6,29 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@prisma/client": "^7.7.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^7.7.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"better-sqlite3": "12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"prisma": "7",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
Generated
+4021
-8
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -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"
|
||||
@@ -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
Binary file not shown.
Reference in New Issue
Block a user