feat: add about page, global stats counter, and blitz screen updates

- Create about page with game modes, technologies, and paliers
- Add stats API endpoint for global game counter
- Update home screen with emoji-based tier system (🌱-🚀)
- Rewrite BlitzScreen to match new puzzle-based win/lose phases
- Add simple-icons package for tech stack icons
- Update metadata with wikirush.xyz domain
- Fix em dashes to hyphens throughout codebase
- Update footer with privacy and about links
This commit is contained in:
jessy-david-dev
2026-04-10 22:26:24 +02:00
parent dc12abf796
commit 47b8de0b67
17 changed files with 487 additions and 2989 deletions
+192
View File
@@ -0,0 +1,192 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
export const metadata: Metadata = {
title: "À propos - WikiRush",
description: "WikiRush, le jeu de navigation Wikipedia.",
};
export default function AboutPage() {
return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] px-4 py-12 flex flex-col items-center">
<div className="w-full max-w-xl flex flex-col gap-10">
<Link
href="/"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium
bg-[#1f1f1f] border border-[#2e2e2e] text-[#e5e5e5]
hover:bg-[#2a2a2a] hover:border-[#3a3a3a]
transition-all duration-200"
>
<span className="text-base"></span>
Retour
</Link>
{/* Hero */}
<div className="flex flex-col gap-2">
<Image
src="/wikirush.png"
alt="WikiRush"
width={120}
height={120}
className="w-24 h-auto mix-blend-screen"
/>
<p className="text-[#888] text-sm sm:text-base leading-relaxed max-w-md">
Le jeu de navigation Wikipedia. Pars d&apos;un article, atteins la
cible en cliquant sur les liens. Simple en apparence, redoutablement
stratégique.
</p>
</div>
{/* Modes */}
<div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Modes de jeu
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{
icon: "🎯",
name: "Solo",
desc: "À ton rythme, chrono et compteur de clics pour battre tes records.",
},
{
icon: "⚔️",
name: "Multijoueur",
desc: "Affronte d'autres joueurs en temps réel. Le premier à la cible gagne.",
},
{
icon: "🗓",
name: "Défi du jour",
desc: "Un puzzle identique pour tout le monde. Classement journalier.",
},
{
icon: "⚡",
name: "Mode Blitz",
desc: "2 minutes pour atteindre la cible. Chaque seconde compte.",
},
].map(({ icon, name, desc }) => (
<div
key={name}
className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-2"
>
<div className="text-2xl">{icon}</div>
<div className="font-bold text-sm">{name}</div>
<div className="text-[#888] text-xs leading-relaxed">
{desc}
</div>
</div>
))}
</div>
</div>
{/* Stack */}
<div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Technologies
</h2>
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-wrap gap-3">
{[
{
name: "Next.js",
color: "#fff",
path: "M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z",
},
{
name: "React",
color: "#61DAFB",
path: "M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z",
},
{
name: "Tailwind CSS",
color: "#06B6D4",
path: "M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z",
},
{
name: "Prisma",
color: "#fff",
path: "M21.8068 18.2848L13.5528.7565c-.207-.4382-.639-.7273-1.1286-.7541-.5023-.0293-.9523.213-1.2062.6253L2.266 15.1271c-.2773.4518-.2718 1.0091.0158 1.4555l4.3759 6.7786c.2608.4046.7127.6388 1.1823.6388.1332 0 .267-.0188.3987-.0577l12.7019-3.7568c.3891-.1151.7072-.3904.8737-.7553s.1633-.7828-.0075-1.1454zm-1.8481.7519L9.1814 22.2242c-.3292.0975-.6448-.1873-.5756-.5194l3.8501-18.4386c.072-.3448.5486-.3996.699-.0803l7.1288 15.138c.1344.2856-.019.6224-.325.7128z",
},
{
name: "SQLite",
color: "#003B57",
path: "M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z",
},
{
name: "Wikipedia",
color: "#fff",
path: "M12.09 13.119c-.936 1.932-2.217 4.548-2.853 5.728-.616 1.074-1.127.931-1.532.029-1.406-3.321-4.293-9.144-5.651-12.409-.251-.601-.441-.987-.619-1.139-.181-.15-.554-.24-1.122-.271C.103 5.033 0 4.982 0 4.898v-.455l.052-.045c.924-.005 5.401 0 5.401 0l.051.045v.434c0 .119-.075.176-.225.176l-.564.031c-.485.029-.727.164-.727.436 0 .135.053.33.166.601 1.082 2.646 4.818 10.521 4.818 10.521l.136.046 2.411-4.81-.482-1.067-1.658-3.264s-.318-.654-.428-.872c-.728-1.443-.712-1.518-1.447-1.617-.207-.023-.313-.05-.313-.149v-.468l.06-.045h4.292l.113.037v.451c0 .105-.076.15-.227.15l-.308.047c-.792.061-.661.381-.136 1.422l1.582 3.252 1.758-3.504c.293-.64.233-.801.111-.947-.07-.084-.305-.22-.812-.24l-.201-.021c-.052 0-.098-.015-.145-.051-.045-.031-.067-.076-.067-.129v-.427l.061-.045c1.247-.008 4.043 0 4.043 0l.059.045v.436c0 .121-.059.178-.193.178-.646.03-.782.095-1.023.439-.12.186-.375.589-.646 1.039l-2.301 4.273-.065.135 2.792 5.712.17.048 4.396-10.438c.154-.422.129-.722-.064-.895-.197-.172-.346-.273-.857-.295l-.42-.016c-.061 0-.105-.014-.152-.045-.043-.029-.072-.075-.072-.119v-.436l.059-.045h4.961l.041.045v.437c0 .119-.074.18-.209.18-.648.03-1.127.18-1.443.421-.314.255-.557.616-.736 1.067 0 0-4.043 9.258-5.426 12.339-.525 1.007-1.053.917-1.503-.031-.571-1.171-1.773-3.786-2.646-5.71l.053-.036z",
},
].map(({ name, color, path }) => (
<div
key={name}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#242424] border border-[#2e2e2e]"
>
<svg
viewBox="0 0 24 24"
className="w-4 h-4 shrink-0"
style={{ fill: color }}
aria-hidden="true"
>
<path d={path} />
</svg>
<span className="text-xs font-medium text-[#aaa]">{name}</span>
</div>
))}
</div>
</div>
{/* Paliers */}
<div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Paliers du compteur total
</h2>
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl divide-y divide-[#2e2e2e]">
{[
{ emoji: "🌱", label: "Moins de 10 parties", range: "< 10" },
{ emoji: "👾", label: "10 parties et plus", range: "10+" },
{ emoji: "🎮", label: "100 parties et plus", range: "100+" },
{ emoji: "🎯", label: "500 parties et plus", range: "500+" },
{ emoji: "⚡", label: "1 000 parties et plus", range: "1 000+" },
{ emoji: "🔥", label: "5 000 parties et plus", range: "5 000+" },
{ emoji: "🚀", label: "10 000 parties et plus", range: "10 000+" },
].map(({ emoji, label, range }) => (
<div key={range} className="flex items-center gap-3 px-4 py-2.5">
<span className="text-xl w-7 text-center shrink-0">{emoji}</span>
<span className="text-sm text-[#aaa] flex-1">{label}</span>
<span className="text-xs font-mono text-[#555]">{range}</span>
</div>
))}
</div>
</div>
{/* Contact */}
<div className="flex flex-col gap-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-[#555]">
Contact
</h2>
<a
href="https://github.com/jessy-david-dev/WikiRush"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-3 bg-[#1a1a1a] border border-[#2e2e2e] hover:border-[#3a3a3a] hover:bg-[#222] transition-colors rounded-xl px-4 py-3 text-sm font-medium text-[#f0f0f0]"
>
<svg
viewBox="0 0 24 24"
className="w-5 h-5 fill-current shrink-0"
aria-hidden="true"
>
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
jessy-david-dev/WikiRush
<span className="ml-auto text-[#555] text-xs">GitHub </span>
</a>
<p className="text-[#888] text-xs leading-relaxed">
Une idée, un bug, une suggestion ? Ouvre une issue sur le dépôt.
</p>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -16,7 +16,7 @@ async function getOrCreatePuzzle() {
return prisma.dailyPuzzle.create({ data: { date, startArticle: start, targetArticle: target } });
}
// GET retourne le puzzle du jour + si l'utilisateur a déjà joué
// 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();
@@ -35,7 +35,7 @@ export async function GET() {
});
}
// POST soumettre un résultat
// 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 });
@@ -50,7 +50,7 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Puzzle invalide" }, { status: 400 });
}
// Upsert on n'enregistre qu'une fois
// 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 },
+2 -2
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../../auth";
import { prisma } from "../../../lib/prisma";
// POST /api/games sauvegarder une partie
// POST /api/games - sauvegarder une partie
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
@@ -36,7 +36,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ id: game.id });
}
// GET /api/games historique de l'utilisateur connecté
// GET /api/games - historique de l'utilisateur connecté
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
export const revalidate = 60; // revalide toutes les 60s
export async function GET() {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayCount = await prisma.game.count({
where: { playedAt: { gte: todayStart } },
});
const totalCount = await prisma.game.count();
return NextResponse.json({ today: todayCount, total: totalCount });
}
+83 -25
View File
@@ -25,7 +25,7 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
<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.
Tu as <span className="text-[#f0f0f0] font-bold">2 minutes</span>{" "}pour naviguer de l&apos;article de départ jusqu&apos;à l&apos;article cible en cliquant sur les liens Wikipedia. Plus tu es rapide, mieux c&apos;est !
</p>
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
{game.loading ? "Préparation..." : "Lancer le chrono !"}
@@ -33,26 +33,37 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
</div>
);
if (game.phase === "ended") return (
if (game.phase === "won") 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="text-5xl sm:text-6xl leading-none">🎉</div>
<h2 className="text-2xl sm:text-3xl font-black">Article trouvé !</h2>
{game.puzzle && (
<p className="text-[#888] text-sm">
<span className="text-[#f0f0f0] font-semibold">{game.puzzle.start}</span>
{" → "}
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span>
</p>
)}
<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>
<span className="text-4xl font-black text-[#7c3aed]">{fmtLeft(game.timeLeft)}</span>
<span className="text-xs text-[#888]">temps restant</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 className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{game.history.length}</span>
<span className="text-xs text-[#888]">articles</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) => (
{game.history.map((t, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-[#555]"></span>}
<span>{t}</span>
<span className={t === game.puzzle?.target ? "text-[#7c3aed] font-bold" : ""}>{t}</span>
</span>
))}
</div>
@@ -60,7 +71,48 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
Rejouer
</button>
<button className={btnGhost} onClick={onBack}>Accueil</button>
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button>
</div>
</div>
</div>
);
if (game.phase === "lost") 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>
{game.puzzle && (
<p className="text-[#888] text-sm">
L&apos;objectif était d&apos;atteindre{" "}
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span>
</p>
)}
<div className="flex gap-8 justify-center">
<div className="flex flex-col gap-1">
<span className="text-4xl font-black text-red-400">{game.clicks}</span>
<span className="text-xs text-[#888]">clics</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-4xl font-black text-red-400">{game.history.length}</span>
<span className="text-xs text-[#888]">articles visités</span>
</div>
</div>
{game.history.length > 0 && (
<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.history.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}>
Réessayer
</button>
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button>
</div>
</div>
</div>
@@ -90,26 +142,32 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
</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 className="fixed bottom-0 left-0 right-0 z-50 bg-[#0f0f0f]/97 backdrop-blur-sm border-t border-[#2e2e2e] px-3 sm:px-4 py-2 flex flex-col gap-1">
<div className="flex items-center justify-between 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]">Clics</span>
<span className="text-sm font-black tabular-nums">{game.clicks}</span>
</div>
</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>
{/* Timer */}
<div className={`text-2xl sm:text-3xl font-black tabular-nums transition-colors ${danger ? "text-red-400 animate-pulse" : "text-[#f0f0f0]"}`}>
{fmtLeft(game.timeLeft)}
</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] : ""}
{game.puzzle && (
<div className="text-right">
<div className="text-[8px] font-bold uppercase tracking-wider text-[#888]">Cible</div>
<div className="text-xs font-bold text-[#7c3aed] max-w-28 truncate">{game.puzzle.target}</div>
</div>
)}
</div>
{game.history.length > 0 && (
<div className="text-[10px] text-[#555] truncate">
{game.history.join(" ")}
</div>
)}
</div>
</div>
);
+1 -1
View File
@@ -62,7 +62,7 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
</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-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>
+1 -1
View File
@@ -148,7 +148,7 @@ export function GameScreen({
)}
</div>
{/* Sidebar desktop seulement */}
{/* Sidebar - desktop seulement */}
<aside className="hidden md:flex w-36 lg:w-44 shrink-0 bg-[#1a1a1a] border-l border-[#2e2e2e] flex-col px-3 py-4 overflow-y-auto gap-3">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-[#888]">Joueurs</h4>
<ul className="flex flex-col gap-2.5">
+98 -22
View File
@@ -1,8 +1,30 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import type { Session } from "next-auth";
function todayEmoji(n: number): string {
if (n >= 10000) return "🚀";
if (n >= 5000) return "🔥";
if (n >= 1000) return "⚡";
if (n >= 500) return "🎯";
if (n >= 100) return "🎮";
if (n >= 10) return "👾";
return "🌱";
}
function useDailyStats() {
const [today, setToday] = useState<number | null>(null); // renommé plus bas en total
useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then((d) => setToday(d.total))
.catch(() => {});
}, []);
return today;
}
type HomeScreenProps = {
playerName: string;
setPlayerName: (v: string) => void;
@@ -23,17 +45,34 @@ type HomeScreenProps = {
};
export function HomeScreen({
playerName, setPlayerName, joinCode, setJoinCode,
error, setError, loading, onCreateRoom, onJoinRoom, onSolo,
session, onShowAuth, onShowProfile, onShowLeaderboard, onDaily, onBlitz,
playerName,
setPlayerName,
joinCode,
setJoinCode,
error,
setError,
loading,
onCreateRoom,
onJoinRoom,
onSolo,
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";
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";
const today = useDailyStats();
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center justify-center px-4 py-16 gap-6 sm:gap-8">
{/* Topbar */}
<div className="fixed top-3 right-3 flex items-center gap-1.5 sm:gap-2 z-50">
<button className={btnGhost} onClick={onShowLeaderboard}>🏆 Classement</button>
<button className={btnGhost} onClick={onShowLeaderboard}>
🏆 Classement
</button>
{session?.user ? (
<button
className="flex items-center gap-1.5 sm:gap-2 bg-[#242424] border border-[#2e2e2e] rounded-full pl-1 pr-2.5 sm:pr-3 py-1 cursor-pointer hover:bg-[#1a1a1a]"
@@ -42,23 +81,41 @@ export function HomeScreen({
<span className="w-7 h-7 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-xs font-bold shrink-0">
{session.user.name?.[0]?.toUpperCase() ?? "?"}
</span>
<span className="hidden sm:block max-w-28 truncate text-sm font-medium">{session.user.name}</span>
<span className="hidden sm:block max-w-28 truncate text-sm font-medium">
{session.user.name}
</span>
</button>
) : (
<button className={btnGhost} onClick={onShowAuth}>Connexion</button>
<button className={btnGhost} onClick={onShowAuth}>
Connexion
</button>
)}
</div>
{/* Hero */}
<div className="text-center">
<Image
src="/wikirush.png" alt="WikiRush" width={320} height={320}
src="/wikirush.png"
alt="WikiRush"
width={320}
height={320}
className="w-40 sm:w-56 md:w-72 h-auto mx-auto mix-blend-screen"
priority
/>
<p className="mt-3 text-sm sm:text-base font-semibold text-[#f0f0f0] max-w-xs sm:max-w-sm mx-auto leading-relaxed tracking-wide">
Navigue entre les articles Wikipedia pour atteindre la cible en premier !
Navigue entre les articles Wikipedia pour atteindre la cible en
premier !
</p>
{today !== null && (
<p className="mt-2 text-xs text-[#888]">
{todayEmoji(today)}{" "}
<span className="font-semibold text-[#f0f0f0]">
{today.toLocaleString("fr-FR")}
</span>{" "}
partie{today > 1 ? "s" : ""} jouée{today > 1 ? "s" : ""} au total
</p>
)}
</div>
{/* Form */}
@@ -66,37 +123,54 @@ export function HomeScreen({
{error && (
<div className="flex items-center justify-between gap-3 bg-red-950/40 border border-red-600 text-red-300 px-3 py-2.5 rounded-xl text-xs sm:text-sm">
{error}
<button onClick={() => setError(null)} className="shrink-0 px-1.5 py-0.5 rounded hover:bg-white/10 cursor-pointer"></button>
<button
onClick={() => setError(null)}
className="shrink-0 px-1.5 py-0.5 rounded hover:bg-white/10 cursor-pointer"
>
</button>
</div>
)}
<input
className="w-full min-h-11 px-3.5 bg-[#1a1a1a] border-[1.5px] border-[#2e2e2e] rounded-xl text-[#f0f0f0] text-sm outline-none focus:border-[#7c3aed] transition-colors"
type="text" placeholder="Ton pseudo"
value={playerName} onChange={(e) => setPlayerName(e.target.value)}
maxLength={20} onKeyDown={(e) => e.key === "Enter" && onCreateRoom()}
type="text"
placeholder="Ton pseudo"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
maxLength={20}
onKeyDown={(e) => e.key === "Enter" && onCreateRoom()}
/>
{/* Multijoueur */}
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 sm:p-5 flex flex-col gap-3">
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">Multijoueur</h3>
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">
Multijoueur
</h3>
<button
className="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"
onClick={onCreateRoom} disabled={loading}
onClick={onCreateRoom}
disabled={loading}
>
{loading ? "Création..." : "Créer une partie"}
</button>
{/* Join row colonne sur mobile, ligne sur sm+ */}
{/* Join row - colonne sur mobile, ligne sur sm+ */}
<div className="flex flex-col sm:flex-row gap-2">
<input
className="flex-1 min-h-11 px-3.5 bg-[#0f0f0f] border-[1.5px] border-[#2e2e2e] rounded-xl text-[#f0f0f0] font-mono text-base sm:text-lg tracking-widest uppercase outline-none focus:border-[#7c3aed] transition-colors"
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()}
type="text"
placeholder="Code de la partie"
value={joinCode}
onChange={(e) =>
setJoinCode(e.target.value.toUpperCase().slice(0, 4))
}
maxLength={4}
onKeyDown={(e) => e.key === "Enter" && onJoinRoom()}
/>
<button
className="w-full sm:w-auto min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white hover:bg-[#1d4ed8] disabled:opacity-50 cursor-pointer shrink-0 transition-colors"
onClick={onJoinRoom} disabled={loading}
onClick={onJoinRoom}
disabled={loading}
>
Rejoindre
</button>
@@ -111,7 +185,9 @@ export function HomeScreen({
{/* Solo */}
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 sm:p-5 flex flex-col gap-3">
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">Solo</h3>
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">
Solo
</h3>
<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={onSolo}
@@ -128,7 +204,7 @@ 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={onBlitz}
>
Mode Blitz 2 min
Mode Blitz - 2 min
</button>
</div>
</div>
+4 -4
View File
@@ -99,19 +99,19 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Victoires</span>
</div>
<div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{stats.avgClicks > 0 ? stats.avgClicks : ""}</span>
<span className="text-lg sm:text-[22px] font-black">{stats.avgClicks > 0 ? stats.avgClicks : "-"}</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Clics moy.</span>
</div>
<div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{stats.avgTime > 0 ? fmt(stats.avgTime) : ""}</span>
<span className="text-lg sm:text-[22px] font-black">{stats.avgTime > 0 ? fmt(stats.avgTime) : "-"}</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Temps moy.</span>
</div>
<div className={`${statCard} border-[#7c3aed]`}>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestClicks > 0 ? stats.bestClicks : ""}</span>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestClicks > 0 ? stats.bestClicks : "-"}</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur clics</span>
</div>
<div className={`${statCard} border-[#7c3aed]`}>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestTime > 0 ? fmt(stats.bestTime) : ""}</span>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestTime > 0 ? fmt(stats.bestTime) : "-"}</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur temps</span>
</div>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
}
/* ================================
Article Wikipedia (HTML externe ne peut pas être tailwindifié)
Article Wikipedia (HTML externe - ne peut pas être tailwindifié)
================================ */
.article-container {
+11
View File
@@ -16,6 +16,15 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "WikiRush - Jeu de navigation Wikipedia",
description: "Navigue entre les articles Wikipedia pour atteindre la cible en premier !",
metadataBase: new URL("https://wikirush.xyz"),
openGraph: {
title: "WikiRush",
description: "Navigue entre les articles Wikipedia pour atteindre la cible en premier !",
url: "https://wikirush.xyz",
siteName: "WikiRush",
locale: "fr_FR",
type: "website",
},
};
export default function RootLayout({
@@ -35,6 +44,8 @@ export default function RootLayout({
<span>© {new Date().getFullYear()} WikiRush</span>
<span>·</span>
<a href="/privacy" className="hover:text-[#888] transition-colors">Politique de confidentialité</a>
<span>·</span>
<a href="/about" className="hover:text-[#888] transition-colors">À propos</a>
</footer>
</Providers>
</body>
+4 -4
View File
@@ -1,5 +1,5 @@
export const metadata = {
title: "Politique de confidentialité WikiRush",
title: "Politique de confidentialité - WikiRush",
};
export default function PrivacyPage() {
@@ -27,9 +27,9 @@ export default function PrivacyPage() {
<h2 className="text-lg sm:text-xl font-bold text-[#f0f0f0]">2. Données collectées</h2>
<p>Lors de la création d&apos;un compte, nous collectons :</p>
<ul className="list-disc pl-5 flex flex-col gap-1.5 text-[#ccc]">
<li><span className="text-[#f0f0f0] font-semibold">Pseudo</span> affiché dans les parties et le classement.</li>
<li><span className="text-[#f0f0f0] font-semibold">Adresse e-mail</span> utilisée uniquement pour l&apos;authentification. Elle n&apos;est jamais affichée publiquement.</li>
<li><span className="text-[#f0f0f0] font-semibold">Mot de passe</span> stocké sous forme hachée (bcrypt). Nous n&apos;avons jamais accès à votre mot de passe en clair.</li>
<li><span className="text-[#f0f0f0] font-semibold">Pseudo</span> - affiché dans les parties et le classement.</li>
<li><span className="text-[#f0f0f0] font-semibold">Adresse e-mail</span> - utilisée uniquement pour l&apos;authentification. Elle n&apos;est jamais affichée publiquement.</li>
<li><span className="text-[#f0f0f0] font-semibold">Mot de passe</span> - stocké sous forme hachée (bcrypt). Nous n&apos;avons jamais accès à votre mot de passe en clair.</li>
</ul>
<p>Pour chaque partie jouée, nous enregistrons :</p>
<ul className="list-disc pl-5 flex flex-col gap-1.5 text-[#ccc]">
+52 -44
View File
@@ -1,11 +1,11 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { fetchArticle, pickTwoArticles } from "./wiki";
import { fetchArticle, pickTwoArticles, normalizeTitle } from "./wiki";
const BLITZ_DURATION = 120; // 2 minutes en secondes
const BLITZ_DURATION = 120; // 2 minutes
export type BlitzPhase = "setup" | "playing" | "ended";
export type BlitzPhase = "setup" | "playing" | "won" | "lost";
export function useBlitzGame() {
const [phase, setPhase] = useState<BlitzPhase>("setup");
@@ -13,18 +13,21 @@ export function useBlitzGame() {
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 [history, setHistory] = useState<string[]>([]);
const [puzzle, setPuzzle] = useState<{ start: string; target: string } | null>(null);
const clicksRef = useRef(0);
const visitedRef = useRef<Set<string>>(new Set());
const visitedListRef = useRef<string[]>([]);
const pathRef = useRef<string[]>([]);
const loadingRef = useRef(false);
const endedRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
const startTimeRef = useRef(0);
function stopTimer() {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
}
function startTimer() {
startTimeRef.current = Date.now();
@@ -32,33 +35,34 @@ export function useBlitzGame() {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
const left = Math.max(0, BLITZ_DURATION - elapsed);
setTimeLeft(left);
if (left <= 0) endGame();
}, 200);
if (left <= 0 && !endedRef.current) {
endedRef.current = true;
stopTimer();
setPhase("lost");
saveGame(false, pathRef.current, clicksRef.current, BLITZ_DURATION);
}
}, 100);
}
function endGame() {
if (endedRef.current) return;
endedRef.current = true;
if (intervalRef.current) clearInterval(intervalRef.current);
setPhase("ended");
// Sauvegarder
useEffect(() => () => stopTimer(), []);
async function saveGame(won: boolean, path: string[], clicks: number, timeSeconds: number) {
if (!puzzle) return;
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,
startArticle: puzzle.start,
targetArticle: puzzle.target,
path,
clicks,
timeSeconds: BLITZ_DURATION - timeLeft,
won,
}),
}).catch(() => {});
}
useEffect(() => () => { if (intervalRef.current) clearInterval(intervalRef.current); }, []);
async function loadArticle(t: string) {
setLoadError(null);
setLoading(true);
@@ -74,21 +78,20 @@ export function useBlitzGame() {
async function start() {
setLoading(true);
stopTimer();
clicksRef.current = 0; setClicks(0);
visitedRef.current = new Set();
visitedListRef.current = [];
pathRef.current = []; setHistory([]);
endedRef.current = false;
setTimeLeft(BLITZ_DURATION);
setVisited([]);
setLoadError(null);
const { start: startArticle } = await pickTwoArticles();
const canonical = await loadArticle(startArticle);
const p = await pickTwoArticles();
setPuzzle(p);
const canonical = await loadArticle(p.start);
if (!canonical) return;
visitedRef.current.add(canonical);
visitedListRef.current = [canonical];
setVisited([canonical]);
pathRef.current = [canonical];
setHistory([canonical]);
setPhase("playing");
startTimer();
}
@@ -101,31 +104,36 @@ export function useBlitzGame() {
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]);
}
const newPath = [...pathRef.current, canonical];
pathRef.current = newPath;
setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" });
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) {
endedRef.current = true;
stopTimer();
setPhase("won");
saveGame(true, newPath, clicksRef.current, timeLeft);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [puzzle, timeLeft]);
function reset() {
if (intervalRef.current) clearInterval(intervalRef.current);
stopTimer();
endedRef.current = false;
clicksRef.current = 0; setClicks(0);
visitedRef.current = new Set();
visitedListRef.current = [];
setVisited([]);
pathRef.current = []; setHistory([]);
setHtml(""); setTitle("");
setTimeLeft(BLITZ_DURATION);
setPuzzle(null);
setPhase("setup");
setLoadError(null);
}
return {
phase, html, title, loading, loadError,
timeLeft, clicks, visited,
phase, puzzle, html, title, loading, loadError,
timeLeft, clicks, history,
canGoBack: false, // pas de retour en blitz
start, navigate, reset,
retryLoad: () => title && loadArticle(title),
};
+1 -1
View File
@@ -19,5 +19,5 @@ export async function saveGame(data: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} catch { /* silencieux pas de compte ou hors ligne */ }
} catch { /* silencieux - pas de compte ou hors ligne */ }
}
+2 -1
View File
@@ -15,7 +15,8 @@
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"simple-icons": "^16.15.0"
},
"devDependencies": {
"@prisma/adapter-better-sqlite3": "^7.7.0",
+15 -2880
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.