feat: add daily challenge and blitz mode (2min speedrun)

This commit is contained in:
jessy-david-dev
2026-04-10 21:41:49 +02:00
parent 8c18984121
commit dc12abf796
21 changed files with 4035 additions and 12 deletions
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function GET() {
const date = new Date().toISOString().slice(0, 10);
const puzzle = await prisma.dailyPuzzle.findUnique({ where: { date } });
if (!puzzle) return NextResponse.json([]);
const results = await prisma.dailyResult.findMany({
where: { puzzleId: puzzle.id, won: true },
include: { user: { select: { name: true } } },
orderBy: [{ clicks: "asc" }, { timeSeconds: "asc" }],
take: 20,
});
return NextResponse.json(results.map((r, i) => ({
rank: i + 1,
name: r.user.name,
clicks: r.clicks,
timeSeconds: r.timeSeconds,
userId: r.userId,
})));
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { auth } from "../../../auth";
import { prisma } from "../../../lib/prisma";
import { pickTwoArticles } from "../../../lib/wiki";
function todayKey() {
return new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
}
async function getOrCreatePuzzle() {
const date = todayKey();
const existing = await prisma.dailyPuzzle.findUnique({ where: { date } });
if (existing) return existing;
const { start, target } = await pickTwoArticles();
return prisma.dailyPuzzle.create({ data: { date, startArticle: start, targetArticle: target } });
}
// GET — retourne le puzzle du jour + si l'utilisateur a déjà joué
export async function GET() {
const session = await auth();
const puzzle = await getOrCreatePuzzle();
let myResult = null;
if (session?.user?.id) {
myResult = await prisma.dailyResult.findUnique({
where: { puzzleId_userId: { puzzleId: puzzle.id, userId: session.user.id } },
});
}
return NextResponse.json({
puzzle: { id: puzzle.id, date: puzzle.date, startArticle: puzzle.startArticle, targetArticle: puzzle.targetArticle },
alreadyPlayed: !!myResult,
myResult: myResult ? { clicks: myResult.clicks, timeSeconds: myResult.timeSeconds, won: myResult.won, path: JSON.parse(myResult.path) } : null,
});
}
// POST — soumettre un résultat
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non connecté" }, { status: 401 });
const { puzzleId, path, clicks, timeSeconds, won } = await req.json() as {
puzzleId: string; path: string[]; clicks: number; timeSeconds: number; won: boolean;
};
// Vérifier que le puzzle est bien celui du jour
const puzzle = await prisma.dailyPuzzle.findUnique({ where: { id: puzzleId } });
if (!puzzle || puzzle.date !== todayKey()) {
return NextResponse.json({ error: "Puzzle invalide" }, { status: 400 });
}
// Upsert — on n'enregistre qu'une fois
const result = await prisma.dailyResult.upsert({
where: { puzzleId_userId: { puzzleId, userId: session.user.id } },
create: { puzzleId, userId: session.user.id, path: JSON.stringify(path), clicks, timeSeconds, won },
update: {},
});
return NextResponse.json({ id: result.id });
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useBlitzGame } from "../../lib/useBlitzGame";
import { ArticleView } from "./ArticleView";
function fmtLeft(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, "0")}`;
}
export function BlitzScreen({ onBack }: { onBack: () => void }) {
const game = useBlitzGame();
const btnPrimary = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
const btnGhost = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
const danger = game.timeLeft < 30;
if (game.phase === "setup") return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center justify-center px-4 py-8 gap-5 max-w-sm mx-auto text-center">
<button className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer" onClick={onBack}>
Retour
</button>
<div className="text-5xl"></div>
<h2 className="text-2xl sm:text-3xl font-black">Mode Blitz</h2>
<p className="text-[#888] text-sm sm:text-base leading-relaxed">
Tu as <span className="text-[#f0f0f0] font-bold">2 minutes</span> pour visiter un maximum d&apos;articles Wikipedia différents en cliquant sur les liens. Ton score = nombre d&apos;articles uniques visités.
</p>
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
{game.loading ? "Préparation..." : "Lancer le chrono !"}
</button>
</div>
);
if (game.phase === "ended") return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8">
<div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-5">
<div className="text-5xl sm:text-6xl leading-none"></div>
<h2 className="text-2xl sm:text-3xl font-black">Temps écoulé !</h2>
<div className="flex gap-8 justify-center">
<div className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{game.visited.length}</span>
<span className="text-xs text-[#888]">articles visités</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{game.clicks}</span>
<span className="text-xs text-[#888]">clics</span>
</div>
</div>
<div className="bg-[#1a1a1a] rounded-xl px-4 py-3 flex flex-wrap gap-1 text-xs text-[#888] text-left max-h-48 overflow-y-auto">
{game.visited.map((t, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-[#555]"></span>}
<span>{t}</span>
</span>
))}
</div>
<div className="flex flex-col gap-2.5">
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
Rejouer
</button>
<button className={btnGhost} onClick={onBack}>Accueil</button>
</div>
</div>
</div>
);
// Playing
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] flex flex-col">
<div className="flex-1 overflow-y-auto bg-white">
<div className="article-container">
{game.title && <h1 className="article-title">{game.title}</h1>}
{game.loading && (
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div>
)}
{game.loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{game.loadError}</p>
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer" onClick={() => game.retryLoad()}>
Réessayer
</button>
</div>
)}
{!game.loading && !game.loadError && game.html && (
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} />
)}
</div>
</div>
{/* Bottom bar */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0f0f0f]/97 backdrop-blur-sm border-t border-[#2e2e2e] flex items-center justify-between px-3 sm:px-4 py-2 gap-3">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center gap-0.5">
<span className="text-[8px] font-bold uppercase tracking-wider text-[#888]"> Articles</span>
<span className="text-sm font-black text-[#7c3aed] tabular-nums">{game.visited.length}</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<span className="text-[8px] font-bold uppercase tracking-wider text-[#888]">Clics</span>
<span className="text-sm font-black tabular-nums">{game.clicks}</span>
</div>
</div>
{/* Timer */}
<div className={`text-2xl sm:text-3xl font-black tabular-nums transition-colors ${danger ? "text-red-400" : "text-[#f0f0f0]"}`}>
{fmtLeft(game.timeLeft)}
</div>
<div className="text-xs text-[#888] text-right max-w-28 truncate">
{game.visited.length > 0 ? game.visited[game.visited.length - 1] : ""}
</div>
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useDailyGame } from "../../lib/useDailyGame";
import { fmt } from "../../lib/utils";
import { ArticleView } from "./ArticleView";
import { Breadcrumbs } from "./Breadcrumbs";
const MEDALS = ["🥇", "🥈", "🥉"];
type LeaderboardEntry = { rank: number; name: string; clicks: number; timeSeconds: number; userId: string };
export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; currentUserId?: string }) {
const game = useDailyGame();
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loadingLb, setLoadingLb] = useState(false);
useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
}, [game.history]);
function fetchLeaderboard() {
setLoadingLb(true);
fetch("/api/daily/leaderboard")
.then((r) => r.json())
.then(setLeaderboard)
.finally(() => setLoadingLb(false));
}
useEffect(() => {
if (game.phase === "won" || game.phase === "gave_up" || game.phase === "already_played") {
fetchLeaderboard();
}
}, [game.phase]);
const btnPrimary = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
const btnGhost = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
// Loading initial
if (game.phase === "loading") return (
<div className="min-h-dvh bg-[#0f0f0f] flex items-center justify-center">
<div className="spinner" />
</div>
);
// Erreur initiale
if (game.loadError && game.phase === "loading") return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] flex flex-col items-center justify-center gap-4 px-4">
<p className="text-[#888]">{game.loadError}</p>
<button className={btnGhost} style={{ width: "auto", padding: "0 20px" }} onClick={onBack}>Retour</button>
</div>
);
// Résultat (won / gave_up / already_played)
if (game.phase === "won" || game.phase === "gave_up" || game.phase === "already_played") {
const result = game.myResult;
return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-lg mx-auto px-4 py-8 gap-6">
<button className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer" onClick={onBack}>
Retour
</button>
<div className="text-center">
<div className="text-xs font-bold text-[#888] uppercase tracking-widest mb-1">Défi du jour {game.puzzle?.date}</div>
<div className="text-2xl sm:text-3xl font-black mb-1">
{game.phase === "won" ? "🎉 Réussi !" : game.phase === "gave_up" ? "😔 Abandonné" : "✅ Déjà joué"}
</div>
<div className="text-sm text-[#888]">
<span className="text-[#f0f0f0] font-semibold">{game.puzzle?.startArticle}</span>
<span className="mx-2"></span>
<span className="text-[#7c3aed] font-semibold">{game.puzzle?.targetArticle}</span>
</div>
</div>
{result && (
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-3">
<div className="flex justify-center gap-8">
<div className="text-center">
<div className="text-3xl font-black text-[#7c3aed]">{result.clicks}</div>
<div className="text-xs text-[#888]">clics</div>
</div>
<div className="text-center">
<div className="text-3xl font-black text-[#7c3aed]">{fmt(result.timeSeconds)}</div>
<div className="text-xs text-[#888]">temps</div>
</div>
</div>
{result.path.length > 0 && (
<div className="flex flex-wrap gap-1 text-xs text-[#888] pt-3 border-t border-[#2e2e2e]">
{result.path.map((t, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && <span className="text-[#555]"></span>}
<span className={i === result.path.length - 1 && result.won ? "text-[#16a34a] font-semibold" : ""}>{t}</span>
</span>
))}
</div>
)}
</div>
)}
{/* Classement du jour */}
<div>
<h3 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Classement du jour</h3>
{loadingLb ? (
<div className="flex justify-center py-6"><div className="spinner" /></div>
) : leaderboard.length === 0 ? (
<p className="text-sm text-[#888] text-center py-4">Aucun résultat pour l&apos;instant.</p>
) : (
<div className="flex flex-col gap-2">
{leaderboard.map((entry, i) => (
<div key={i} className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl border ${entry.userId === currentUserId ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}>
<span className="text-lg w-7 text-center shrink-0">{MEDALS[i] ?? `#${entry.rank}`}</span>
<span className="flex-1 font-semibold text-sm truncate">{entry.name}</span>
<span className="text-xs text-[#888] tabular-nums shrink-0">{entry.clicks} clics · {fmt(entry.timeSeconds)}</span>
</div>
))}
</div>
)}
</div>
<button className={btnGhost} onClick={onBack}>Retour à l&apos;accueil</button>
</div>
);
}
// Playing
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] flex flex-col">
<div className="flex-1 overflow-y-auto bg-white">
<div className="article-container">
{game.title && <h1 className="article-title">{game.title}</h1>}
{game.loading && (
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div>
)}
{game.loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{game.loadError}</p>
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer" onClick={() => game.retryLoad()}>
Réessayer
</button>
</div>
)}
{!game.loading && !game.loadError && game.html && (
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} />
)}
</div>
</div>
{/* Bottom bar */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0f0f0f]/97 backdrop-blur-sm border-t border-[#2e2e2e] flex items-center justify-between px-3 sm:px-4 py-2 gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<span className="text-[8px] font-bold uppercase tracking-widest text-[#888] shrink-0">🗓 Défi du jour · cible</span>
<span className="text-xs font-black text-[#7c3aed] truncate">{game.puzzle?.targetArticle}</span>
</div>
<Breadcrumbs history={game.history} endRef={breadcrumbEndRef} />
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex flex-col items-center gap-0.5 min-w-10">
<span className="text-[8px] font-bold tracking-wider text-[#888] uppercase">Clics</span>
<span className="text-xs font-black tabular-nums">{game.clicks}</span>
</div>
{game.canGoBack && (
<button className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] disabled:opacity-50 cursor-pointer" onClick={game.goBack} disabled={game.loading}>
+1
</button>
)}
<button className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-red-950/40 border border-red-900 text-red-400 hover:bg-red-900/40 cursor-pointer" onClick={game.giveUp}>
Abandonner
</button>
</div>
</div>
</div>
);
}
+16 -2
View File
@@ -18,12 +18,14 @@ type HomeScreenProps = {
onShowAuth: () => void;
onShowProfile: () => void;
onShowLeaderboard: () => void;
onDaily: () => void;
onBlitz: () => void;
};
export function HomeScreen({
playerName, setPlayerName, joinCode, setJoinCode,
error, setError, loading, onCreateRoom, onJoinRoom, onSolo,
session, onShowAuth, onShowProfile, onShowLeaderboard,
session, onShowAuth, onShowProfile, onShowLeaderboard, onDaily, onBlitz,
}: HomeScreenProps) {
const btnGhost = "min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer whitespace-nowrap";
@@ -114,7 +116,19 @@ export function HomeScreen({
className="w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors"
onClick={onSolo}
>
Jouer en solo
🎯 Jouer en solo
</button>
<button
className="w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors"
onClick={onDaily}
>
🗓 Défi du jour
</button>
<button
className="w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors"
onClick={onBlitz}
>
Mode Blitz 2 min
</button>
</div>
</div>
+12
View File
@@ -14,6 +14,8 @@ import { GameScreen } from "./GameScreen";
import { AuthModal } from "./AuthModal";
import { ProfileScreen } from "./ProfileScreen";
import { LeaderboardScreen } from "./LeaderboardScreen";
import { DailyScreen } from "./DailyScreen";
import { BlitzScreen } from "./BlitzScreen";
type Props = {
screen: Screen;
@@ -46,6 +48,14 @@ export function ScreenRouter({
<LeaderboardScreen onBack={() => setScreen("home")} />
);
if (screen === "daily") return (
<DailyScreen onBack={() => setScreen("home")} currentUserId={session?.user?.id ?? undefined} />
);
if (screen === "blitz") return (
<BlitzScreen onBack={() => setScreen("home")} />
);
if (screen === "home") return (
<>
<HomeScreen
@@ -59,6 +69,8 @@ export function ScreenRouter({
onShowAuth={() => setShowAuth(true)}
onShowProfile={() => setScreen("profile")}
onShowLeaderboard={() => setScreen("leaderboard")}
onDaily={() => setScreen("daily")}
onBlitz={() => setScreen("blitz")}
/>
{showAuth && <AuthModal onClose={() => setShowAuth(false)} onSuccess={() => setShowAuth(false)} />}
</>
+10
View File
@@ -27,3 +27,13 @@ export type User = Prisma.UserModel
*
*/
export type Game = Prisma.GameModel
/**
* Model DailyPuzzle
*
*/
export type DailyPuzzle = Prisma.DailyPuzzleModel
/**
* Model DailyResult
*
*/
export type DailyResult = Prisma.DailyResultModel
+10
View File
@@ -51,3 +51,13 @@ export type User = Prisma.UserModel
*
*/
export type Game = Prisma.GameModel
/**
* Model DailyPuzzle
*
*/
export type DailyPuzzle = Prisma.DailyPuzzleModel
/**
* Model DailyResult
*
*/
export type DailyResult = Prisma.DailyResultModel
File diff suppressed because one or more lines are too long
@@ -385,7 +385,9 @@ type FieldRefInputType<Model, FieldType> = Model extends never ? never : FieldRe
export const ModelName = {
User: 'User',
Game: 'Game'
Game: 'Game',
DailyPuzzle: 'DailyPuzzle',
DailyResult: 'DailyResult'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -401,7 +403,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "game"
modelProps: "user" | "game" | "dailyPuzzle" | "dailyResult"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -553,6 +555,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
DailyPuzzle: {
payload: Prisma.$DailyPuzzlePayload<ExtArgs>
fields: Prisma.DailyPuzzleFieldRefs
operations: {
findUnique: {
args: Prisma.DailyPuzzleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload> | null
}
findUniqueOrThrow: {
args: Prisma.DailyPuzzleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
findFirst: {
args: Prisma.DailyPuzzleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload> | null
}
findFirstOrThrow: {
args: Prisma.DailyPuzzleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
findMany: {
args: Prisma.DailyPuzzleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>[]
}
create: {
args: Prisma.DailyPuzzleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
createMany: {
args: Prisma.DailyPuzzleCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.DailyPuzzleCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>[]
}
delete: {
args: Prisma.DailyPuzzleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
update: {
args: Prisma.DailyPuzzleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
deleteMany: {
args: Prisma.DailyPuzzleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.DailyPuzzleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.DailyPuzzleUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>[]
}
upsert: {
args: Prisma.DailyPuzzleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyPuzzlePayload>
}
aggregate: {
args: Prisma.DailyPuzzleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateDailyPuzzle>
}
groupBy: {
args: Prisma.DailyPuzzleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.DailyPuzzleGroupByOutputType>[]
}
count: {
args: Prisma.DailyPuzzleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.DailyPuzzleCountAggregateOutputType> | number
}
}
}
DailyResult: {
payload: Prisma.$DailyResultPayload<ExtArgs>
fields: Prisma.DailyResultFieldRefs
operations: {
findUnique: {
args: Prisma.DailyResultFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload> | null
}
findUniqueOrThrow: {
args: Prisma.DailyResultFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
findFirst: {
args: Prisma.DailyResultFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload> | null
}
findFirstOrThrow: {
args: Prisma.DailyResultFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
findMany: {
args: Prisma.DailyResultFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>[]
}
create: {
args: Prisma.DailyResultCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
createMany: {
args: Prisma.DailyResultCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.DailyResultCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>[]
}
delete: {
args: Prisma.DailyResultDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
update: {
args: Prisma.DailyResultUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
deleteMany: {
args: Prisma.DailyResultDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.DailyResultUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.DailyResultUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>[]
}
upsert: {
args: Prisma.DailyResultUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$DailyResultPayload>
}
aggregate: {
args: Prisma.DailyResultAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateDailyResult>
}
groupBy: {
args: Prisma.DailyResultGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.DailyResultGroupByOutputType>[]
}
count: {
args: Prisma.DailyResultCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.DailyResultCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -616,6 +766,30 @@ export const GameScalarFieldEnum = {
export type GameScalarFieldEnum = (typeof GameScalarFieldEnum)[keyof typeof GameScalarFieldEnum]
export const DailyPuzzleScalarFieldEnum = {
id: 'id',
date: 'date',
startArticle: 'startArticle',
targetArticle: 'targetArticle'
} as const
export type DailyPuzzleScalarFieldEnum = (typeof DailyPuzzleScalarFieldEnum)[keyof typeof DailyPuzzleScalarFieldEnum]
export const DailyResultScalarFieldEnum = {
id: 'id',
puzzleId: 'puzzleId',
userId: 'userId',
path: 'path',
clicks: 'clicks',
timeSeconds: 'timeSeconds',
won: 'won',
playedAt: 'playedAt'
} as const
export type DailyResultScalarFieldEnum = (typeof DailyResultScalarFieldEnum)[keyof typeof DailyResultScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -761,6 +935,8 @@ export type PrismaClientOptions = ({
export type GlobalOmitConfig = {
user?: Prisma.UserOmit
game?: Prisma.GameOmit
dailyPuzzle?: Prisma.DailyPuzzleOmit
dailyResult?: Prisma.DailyResultOmit
}
/* Types for Logging */
@@ -52,7 +52,9 @@ export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Game: 'Game'
Game: 'Game',
DailyPuzzle: 'DailyPuzzle',
DailyResult: 'DailyResult'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -95,6 +97,30 @@ export const GameScalarFieldEnum = {
export type GameScalarFieldEnum = (typeof GameScalarFieldEnum)[keyof typeof GameScalarFieldEnum]
export const DailyPuzzleScalarFieldEnum = {
id: 'id',
date: 'date',
startArticle: 'startArticle',
targetArticle: 'targetArticle'
} as const
export type DailyPuzzleScalarFieldEnum = (typeof DailyPuzzleScalarFieldEnum)[keyof typeof DailyPuzzleScalarFieldEnum]
export const DailyResultScalarFieldEnum = {
id: 'id',
puzzleId: 'puzzleId',
userId: 'userId',
path: 'path',
clicks: 'clicks',
timeSeconds: 'timeSeconds',
won: 'won',
playedAt: 'playedAt'
} as const
export type DailyResultScalarFieldEnum = (typeof DailyResultScalarFieldEnum)[keyof typeof DailyResultScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+2
View File
@@ -10,4 +10,6 @@
*/
export type * from './models/User'
export type * from './models/Game'
export type * from './models/DailyPuzzle'
export type * from './models/DailyResult'
export type * from './commonInputTypes'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -183,6 +183,7 @@ export type UserWhereInput = {
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
games?: Prisma.GameListRelationFilter
dailyResults?: Prisma.DailyResultListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -192,6 +193,7 @@ export type UserOrderByWithRelationInput = {
password?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
games?: Prisma.GameOrderByRelationAggregateInput
dailyResults?: Prisma.DailyResultOrderByRelationAggregateInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -204,6 +206,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
games?: Prisma.GameListRelationFilter
dailyResults?: Prisma.DailyResultListRelationFilter
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -235,6 +238,7 @@ export type UserCreateInput = {
password: string
createdAt?: Date | string
games?: Prisma.GameCreateNestedManyWithoutUserInput
dailyResults?: Prisma.DailyResultCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -244,6 +248,7 @@ export type UserUncheckedCreateInput = {
password: string
createdAt?: Date | string
games?: Prisma.GameUncheckedCreateNestedManyWithoutUserInput
dailyResults?: Prisma.DailyResultUncheckedCreateNestedManyWithoutUserInput
}
export type UserUpdateInput = {
@@ -253,6 +258,7 @@ export type UserUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
games?: Prisma.GameUpdateManyWithoutUserNestedInput
dailyResults?: Prisma.DailyResultUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -262,6 +268,7 @@ export type UserUncheckedUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
games?: Prisma.GameUncheckedUpdateManyWithoutUserNestedInput
dailyResults?: Prisma.DailyResultUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -339,12 +346,27 @@ export type UserUpdateOneRequiredWithoutGamesNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutGamesInput, Prisma.UserUpdateWithoutGamesInput>, Prisma.UserUncheckedUpdateWithoutGamesInput>
}
export type UserCreateNestedOneWithoutDailyResultsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutDailyResultsInput, Prisma.UserUncheckedCreateWithoutDailyResultsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutDailyResultsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutDailyResultsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutDailyResultsInput, Prisma.UserUncheckedCreateWithoutDailyResultsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutDailyResultsInput
upsert?: Prisma.UserUpsertWithoutDailyResultsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutDailyResultsInput, Prisma.UserUpdateWithoutDailyResultsInput>, Prisma.UserUncheckedUpdateWithoutDailyResultsInput>
}
export type UserCreateWithoutGamesInput = {
id?: string
name: string
email: string
password: string
createdAt?: Date | string
dailyResults?: Prisma.DailyResultCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutGamesInput = {
@@ -353,6 +375,7 @@ export type UserUncheckedCreateWithoutGamesInput = {
email: string
password: string
createdAt?: Date | string
dailyResults?: Prisma.DailyResultUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutGamesInput = {
@@ -377,6 +400,7 @@ export type UserUpdateWithoutGamesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
dailyResults?: Prisma.DailyResultUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutGamesInput = {
@@ -385,6 +409,59 @@ export type UserUncheckedUpdateWithoutGamesInput = {
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
dailyResults?: Prisma.DailyResultUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutDailyResultsInput = {
id?: string
name: string
email: string
password: string
createdAt?: Date | string
games?: Prisma.GameCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutDailyResultsInput = {
id?: string
name: string
email: string
password: string
createdAt?: Date | string
games?: Prisma.GameUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutDailyResultsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutDailyResultsInput, Prisma.UserUncheckedCreateWithoutDailyResultsInput>
}
export type UserUpsertWithoutDailyResultsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutDailyResultsInput, Prisma.UserUncheckedUpdateWithoutDailyResultsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutDailyResultsInput, Prisma.UserUncheckedCreateWithoutDailyResultsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutDailyResultsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutDailyResultsInput, Prisma.UserUncheckedUpdateWithoutDailyResultsInput>
}
export type UserUpdateWithoutDailyResultsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
games?: Prisma.GameUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutDailyResultsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
games?: Prisma.GameUncheckedUpdateManyWithoutUserNestedInput
}
@@ -394,10 +471,12 @@ export type UserUncheckedUpdateWithoutGamesInput = {
export type UserCountOutputType = {
games: number
dailyResults: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
games?: boolean | UserCountOutputTypeCountGamesArgs
dailyResults?: boolean | UserCountOutputTypeCountDailyResultsArgs
}
/**
@@ -417,6 +496,13 @@ export type UserCountOutputTypeCountGamesArgs<ExtArgs extends runtime.Types.Exte
where?: Prisma.GameWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountDailyResultsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.DailyResultWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -425,6 +511,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
password?: boolean
createdAt?: boolean
games?: boolean | Prisma.User$gamesArgs<ExtArgs>
dailyResults?: boolean | Prisma.User$dailyResultsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -455,6 +542,7 @@ export type UserSelectScalar = {
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "password" | "createdAt", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
games?: boolean | Prisma.User$gamesArgs<ExtArgs>
dailyResults?: boolean | Prisma.User$dailyResultsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -464,6 +552,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
name: "User"
objects: {
games: Prisma.$GamePayload<ExtArgs>[]
dailyResults: Prisma.$DailyResultPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -866,6 +955,7 @@ readonly fields: UserFieldRefs;
export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
games<T extends Prisma.User$gamesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$gamesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GamePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
dailyResults<T extends Prisma.User$dailyResultsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$dailyResultsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$DailyResultPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1314,6 +1404,30 @@ export type User$gamesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
distinct?: Prisma.GameScalarFieldEnum | Prisma.GameScalarFieldEnum[]
}
/**
* User.dailyResults
*/
export type User$dailyResultsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the DailyResult
*/
select?: Prisma.DailyResultSelect<ExtArgs> | null
/**
* Omit specific fields from the DailyResult
*/
omit?: Prisma.DailyResultOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.DailyResultInclude<ExtArgs> | null
where?: Prisma.DailyResultWhereInput
orderBy?: Prisma.DailyResultOrderByWithRelationInput | Prisma.DailyResultOrderByWithRelationInput[]
cursor?: Prisma.DailyResultWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.DailyResultScalarFieldEnum | Prisma.DailyResultScalarFieldEnum[]
}
/**
* User without action
*/
+1 -1
View File
@@ -1,4 +1,4 @@
export type Screen = "home" | "lobby" | "game" | "solo" | "profile" | "leaderboard";
export type Screen = "home" | "lobby" | "game" | "solo" | "profile" | "leaderboard" | "daily" | "blitz";
export type WikiArticle = {
title: string;
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchArticle, pickTwoArticles } from "./wiki";
const BLITZ_DURATION = 120; // 2 minutes en secondes
export type BlitzPhase = "setup" | "playing" | "ended";
export function useBlitzGame() {
const [phase, setPhase] = useState<BlitzPhase>("setup");
const [html, setHtml] = useState("");
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [timeLeft, setTimeLeft] = useState(BLITZ_DURATION);
const [clicks, setClicks] = useState(0);
const [visited, setVisited] = useState<string[]>([]); // articles uniques
const clicksRef = useRef(0);
const visitedRef = useRef<Set<string>>(new Set());
const visitedListRef = useRef<string[]>([]);
const loadingRef = useRef(false);
const endedRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
function startTimer() {
startTimeRef.current = Date.now();
intervalRef.current = setInterval(() => {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
const left = Math.max(0, BLITZ_DURATION - elapsed);
setTimeLeft(left);
if (left <= 0) endGame();
}, 200);
}
function endGame() {
if (endedRef.current) return;
endedRef.current = true;
if (intervalRef.current) clearInterval(intervalRef.current);
setPhase("ended");
// Sauvegarder
fetch("/api/games", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "blitz",
startArticle: visitedListRef.current[0] ?? "",
targetArticle: "",
path: visitedListRef.current,
clicks: clicksRef.current,
timeSeconds: BLITZ_DURATION,
won: true,
}),
}).catch(() => {});
}
useEffect(() => () => { if (intervalRef.current) clearInterval(intervalRef.current); }, []);
async function loadArticle(t: string) {
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; setClicks(0);
visitedRef.current = new Set();
visitedListRef.current = [];
endedRef.current = false;
setTimeLeft(BLITZ_DURATION);
setVisited([]);
setLoadError(null);
const { start: startArticle } = await pickTwoArticles();
const canonical = await loadArticle(startArticle);
if (!canonical) return;
visitedRef.current.add(canonical);
visitedListRef.current = [canonical];
setVisited([canonical]);
setPhase("playing");
startTimer();
}
const navigate = useCallback(async (t: string) => {
if (loadingRef.current || endedRef.current) return;
clicksRef.current += 1;
setClicks(clicksRef.current);
const canonical = await loadArticle(t);
if (!canonical) return;
if (!visitedRef.current.has(canonical)) {
visitedRef.current.add(canonical);
visitedListRef.current = [...visitedListRef.current, canonical];
setVisited([...visitedListRef.current]);
}
window.scrollTo({ top: 0, behavior: "smooth" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function reset() {
if (intervalRef.current) clearInterval(intervalRef.current);
endedRef.current = false;
clicksRef.current = 0; setClicks(0);
visitedRef.current = new Set();
visitedListRef.current = [];
setVisited([]);
setHtml(""); setTitle("");
setTimeLeft(BLITZ_DURATION);
setPhase("setup");
setLoadError(null);
}
return {
phase, html, title, loading, loadError,
timeLeft, clicks, visited,
start, navigate, reset,
retryLoad: () => title && loadArticle(title),
};
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchArticle, normalizeTitle } from "./wiki";
import { useTimer } from "./useTimer";
export type DailyPhase = "loading" | "playing" | "won" | "gave_up" | "already_played";
export type DailyPuzzleInfo = {
id: string;
date: string;
startArticle: string;
targetArticle: string;
};
export type DailyResult = {
clicks: number;
timeSeconds: number;
won: boolean;
path: string[];
};
export function useDailyGame() {
const timer = useTimer();
const [phase, setPhase] = useState<DailyPhase>("loading");
const [puzzle, setPuzzle] = useState<DailyPuzzleInfo | null>(null);
const [myResult, setMyResult] = useState<DailyResult | null>(null);
const [html, setHtml] = useState("");
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const clicksRef = useRef(0);
const [clicks, setClicks] = useState(0);
const pathRef = useRef<string[]>([]);
const [history, setHistory] = useState<string[]>([]);
const timerStartedRef = useRef(false);
const gameEndedRef = useRef(false);
const loadingRef = useRef(false);
useEffect(() => {
fetch("/api/daily")
.then((r) => r.json())
.then(async (data: { puzzle: DailyPuzzleInfo; alreadyPlayed: boolean; myResult: DailyResult | null }) => {
setPuzzle(data.puzzle);
if (data.alreadyPlayed && data.myResult) {
setMyResult(data.myResult);
setPhase("already_played");
return;
}
// Charger l'article de départ
const art = await fetchArticle(data.puzzle.startArticle);
if (!art) { setLoadError("Impossible de charger l'article de départ."); return; }
setHtml(art.html);
setTitle(art.title);
pathRef.current = [art.title];
setHistory([art.title]);
setPhase("playing");
})
.catch(() => setLoadError("Impossible de charger le défi du jour."));
}, []);
async function loadArticle(t: string) {
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 submitResult(won: boolean) {
if (!puzzle) return;
const result: DailyResult = {
clicks: clicksRef.current,
timeSeconds: timer.elapsed,
won,
path: pathRef.current,
};
setMyResult(result);
await fetch("/api/daily", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ puzzleId: puzzle.id, ...result, path: result.path }),
}).catch(() => {});
}
const navigate = useCallback(async (t: string) => {
if (loadingRef.current || gameEndedRef.current) return;
clicksRef.current += 1;
setClicks(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.targetArticle)) {
timer.stop();
gameEndedRef.current = true;
setPhase("won");
await submitResult(true);
}
// 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;
setClicks(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
}, []);
async function giveUp() {
timer.stop();
gameEndedRef.current = true;
setPhase("gave_up");
await submitResult(false);
}
return {
phase, puzzle, myResult,
html, title, loading, loadError,
history, clicks, elapsed: timer.elapsed,
canGoBack: pathRef.current.length > 1,
navigate, goBack, giveUp,
retryLoad: () => title && loadArticle(title),
};
}
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "DailyPuzzle" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" TEXT NOT NULL,
"startArticle" TEXT NOT NULL,
"targetArticle" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "DailyResult" (
"id" TEXT NOT NULL PRIMARY KEY,
"puzzleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"clicks" INTEGER NOT NULL,
"timeSeconds" REAL NOT NULL,
"won" BOOLEAN NOT NULL,
"playedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DailyResult_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DailyResult_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "DailyPuzzle_date_key" ON "DailyPuzzle"("date");
-- CreateIndex
CREATE INDEX "DailyResult_puzzleId_idx" ON "DailyResult"("puzzleId");
-- CreateIndex
CREATE UNIQUE INDEX "DailyResult_puzzleId_userId_key" ON "DailyResult"("puzzleId", "userId");
+27 -2
View File
@@ -15,14 +15,15 @@ model User {
email String @unique
password String
createdAt DateTime @default(now())
games Game[]
games Game[]
dailyResults DailyResult[]
}
model Game {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mode String // "solo" | "multi"
mode String // "solo" | "multi" | "daily" | "blitz"
startArticle String
targetArticle String
path String // JSON array de titres
@@ -33,3 +34,27 @@ model Game {
@@index([userId])
}
model DailyPuzzle {
id String @id @default(cuid())
date String @unique // "YYYY-MM-DD"
startArticle String
targetArticle String
results DailyResult[]
}
model DailyResult {
id String @id @default(cuid())
puzzleId String
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
path String // JSON
clicks Int
timeSeconds Float
won Boolean
playedAt DateTime @default(now())
@@unique([puzzleId, userId])
@@index([puzzleId])
}
BIN
View File
Binary file not shown.