style: apply prettier formatting across codebase

This commit is contained in:
jessy-david-dev
2026-04-11 22:25:34 +02:00
parent b76fbf8789
commit d268905f2d
27 changed files with 2092 additions and 828 deletions
+8 -2
View File
@@ -150,10 +150,16 @@ export default function AboutPage() {
{ emoji: "🎯", label: "500 parties et plus", range: "500+" }, { emoji: "🎯", label: "500 parties et plus", range: "500+" },
{ emoji: "⚡", label: "1 000 parties et plus", range: "1 000+" }, { emoji: "⚡", label: "1 000 parties et plus", range: "1 000+" },
{ emoji: "🔥", label: "5 000 parties et plus", range: "5 000+" }, { emoji: "🔥", label: "5 000 parties et plus", range: "5 000+" },
{ emoji: "🚀", label: "10 000 parties et plus", range: "10 000+" }, {
emoji: "🚀",
label: "10 000 parties et plus",
range: "10 000+",
},
].map(({ emoji, label, range }) => ( ].map(({ emoji, label, range }) => (
<div key={range} className="flex items-center gap-3 px-4 py-2.5"> <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-xl w-7 text-center shrink-0">
{emoji}
</span>
<span className="text-sm text-[#aaa] flex-1">{label}</span> <span className="text-sm text-[#aaa] flex-1">{label}</span>
<span className="text-xs font-mono text-[#555]">{range}</span> <span className="text-xs font-mono text-[#555]">{range}</span>
</div> </div>
+260 -65
View File
@@ -62,15 +62,21 @@ function MiniBarChart({ data }: { data: ActivityDay[] }) {
<div className="flex items-end gap-1 h-16"> <div className="flex items-end gap-1 h-16">
{data.map((d) => { {data.map((d) => {
const pct = (d.count / max) * 100; const pct = (d.count / max) * 100;
const day = new Date(d.date + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "short" }); const day = new Date(d.date + "T12:00:00").toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
});
return ( return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-1 group relative"> <div
key={d.date}
className="flex-1 flex flex-col items-center gap-1 group relative"
>
<div <div
className="w-full bg-[#7c3aed]/60 rounded-sm group-hover:bg-[#7c3aed] transition-colors" className="w-full bg-[#7c3aed]/60 rounded-sm group-hover:bg-[#7c3aed] transition-colors"
style={{ height: `${Math.max(pct, 4)}%` }} style={{ height: `${Math.max(pct, 4)}%` }}
/> />
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-[#2e2e2e] text-[#f0f0f0] text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"> <div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-[#2e2e2e] text-[#f0f0f0] text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
{day} {d.count} {day} - {d.count}
</div> </div>
</div> </div>
); );
@@ -83,19 +89,32 @@ export default function AdminPage() {
const [data, setData] = useState<AdminStats | null>(null); const [data, setData] = useState<AdminStats | null>(null);
const [dailyPuzzles, setDailyPuzzles] = useState<DailyPuzzle[] | null>(null); const [dailyPuzzles, setDailyPuzzles] = useState<DailyPuzzle[] | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<"overview" | "users" | "games" | "daily">("overview"); const [tab, setTab] = useState<"overview" | "users" | "games" | "daily">(
"overview",
);
// Daily puzzle form // Daily puzzle form
const [puzzleForm, setPuzzleForm] = useState({ date: "", startArticle: "", targetArticle: "" }); const [puzzleForm, setPuzzleForm] = useState({
date: "",
startArticle: "",
targetArticle: "",
});
const [editingPuzzle, setEditingPuzzle] = useState<DailyPuzzle | null>(null); const [editingPuzzle, setEditingPuzzle] = useState<DailyPuzzle | null>(null);
const [puzzleSaving, setPuzzleSaving] = useState(false); const [puzzleSaving, setPuzzleSaving] = useState(false);
// Confirm delete // Confirm delete
const [confirmDelete, setConfirmDelete] = useState<{ type: "user" | "puzzle"; id: string; label: string } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{
type: "user" | "puzzle";
id: string;
label: string;
} | null>(null);
function refreshStats() { function refreshStats() {
return fetch("/api/admin/stats") return fetch("/api/admin/stats")
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => {
if (!r.ok) throw new Error();
return r.json();
})
.then(setData); .then(setData);
} }
@@ -139,7 +158,10 @@ export default function AdminPage() {
await fetch(`/api/admin/daily/${editingPuzzle.id}`, { await fetch(`/api/admin/daily/${editingPuzzle.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startArticle: puzzleForm.startArticle, targetArticle: puzzleForm.targetArticle }), body: JSON.stringify({
startArticle: puzzleForm.startArticle,
targetArticle: puzzleForm.targetArticle,
}),
}); });
} else { } else {
await fetch("/api/admin/daily", { await fetch("/api/admin/daily", {
@@ -158,15 +180,22 @@ export default function AdminPage() {
function startEditPuzzle(p: DailyPuzzle) { function startEditPuzzle(p: DailyPuzzle) {
setEditingPuzzle(p); setEditingPuzzle(p);
setPuzzleForm({ date: p.date, startArticle: p.startArticle, targetArticle: p.targetArticle }); setPuzzleForm({
date: p.date,
startArticle: p.startArticle,
targetArticle: p.targetArticle,
});
} }
const card = "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4"; const card = "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4";
const tabBtn = (active: boolean) => const tabBtn = (active: boolean) =>
`px-3 py-2 rounded-lg text-xs sm:text-sm font-semibold transition-colors cursor-pointer ${ `px-3 py-2 rounded-lg text-xs sm:text-sm font-semibold transition-colors cursor-pointer ${
active ? "bg-[#7c3aed] text-white" : "bg-[#1a1a1a] border border-[#2e2e2e] text-[#888] hover:text-[#f0f0f0]" active
? "bg-[#7c3aed] text-white"
: "bg-[#1a1a1a] border border-[#2e2e2e] text-[#888] hover:text-[#f0f0f0]"
}`; }`;
const inputCls = "w-full min-h-10 px-3 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] transition-colors"; const inputCls =
"w-full min-h-10 px-3 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] transition-colors";
return ( return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] px-4 py-8 flex flex-col gap-6 max-w-5xl mx-auto"> <div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] px-4 py-8 flex flex-col gap-6 max-w-5xl mx-auto">
@@ -174,27 +203,53 @@ export default function AdminPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-black">Admin</h1> <h1 className="text-2xl font-black">Admin</h1>
<p className="text-xs text-[#555] mt-0.5">WikiRush panneau d&apos;administration</p> <p className="text-xs text-[#555] mt-0.5">
WikiRush - panneau d&apos;administration
</p>
</div> </div>
<Link href="/" className="min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] transition-colors"> <Link
href="/"
className="min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] transition-colors"
>
Accueil Accueil
</Link> </Link>
</div> </div>
{loading && <div className="text-[#888] text-sm animate-pulse">Chargement...</div>} {loading && (
{!loading && !data && <div className="text-red-400 text-sm">Accès refusé ou erreur serveur.</div>} <div className="text-[#888] text-sm animate-pulse">Chargement...</div>
)}
{!loading && !data && (
<div className="text-red-400 text-sm">
Accès refusé ou erreur serveur.
</div>
)}
{/* Confirm dialog */} {/* Confirm dialog */}
{confirmDelete && ( {confirmDelete && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-2xl p-6 max-w-sm w-full flex flex-col gap-4"> <div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-2xl p-6 max-w-sm w-full flex flex-col gap-4">
<p className="font-semibold text-sm">Confirmer la suppression</p> <p className="font-semibold text-sm">Confirmer la suppression</p>
<p className="text-[#888] text-sm">Supprimer <span className="text-[#f0f0f0] font-semibold">{confirmDelete.label}</span> ? Cette action est irréversible.</p> <p className="text-[#888] text-sm">
Supprimer{" "}
<span className="text-[#f0f0f0] font-semibold">
{confirmDelete.label}
</span>{" "}
? Cette action est irréversible.
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] cursor-pointer hover:bg-[#2e2e2e]" onClick={() => setConfirmDelete(null)}>Annuler</button> <button
className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] cursor-pointer hover:bg-[#2e2e2e]"
onClick={() => setConfirmDelete(null)}
>
Annuler
</button>
<button <button
className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-red-700 text-white cursor-pointer hover:bg-red-600" className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-red-700 text-white cursor-pointer hover:bg-red-600"
onClick={() => confirmDelete.type === "user" ? handleDeleteUser(confirmDelete.id) : handleDeletePuzzle(confirmDelete.id)} onClick={() =>
confirmDelete.type === "user"
? handleDeleteUser(confirmDelete.id)
: handleDeletePuzzle(confirmDelete.id)
}
> >
Supprimer Supprimer
</button> </button>
@@ -207,12 +262,31 @@ export default function AdminPage() {
<> <>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<button className={tabBtn(tab === "overview")} onClick={() => setTab("overview")}>Vue d&apos;ensemble</button> <button
<button className={tabBtn(tab === "users")} onClick={() => setTab("users")}> className={tabBtn(tab === "overview")}
Utilisateurs <span className="ml-1 text-[#555]">({data.totalUsers})</span> onClick={() => setTab("overview")}
>
Vue d&apos;ensemble
</button>
<button
className={tabBtn(tab === "users")}
onClick={() => setTab("users")}
>
Utilisateurs{" "}
<span className="ml-1 text-[#555]">({data.totalUsers})</span>
</button>
<button
className={tabBtn(tab === "games")}
onClick={() => setTab("games")}
>
Parties récentes
</button>
<button
className={tabBtn(tab === "daily")}
onClick={() => setTab("daily")}
>
Défis du jour
</button> </button>
<button className={tabBtn(tab === "games")} onClick={() => setTab("games")}>Parties récentes</button>
<button className={tabBtn(tab === "daily")} onClick={() => setTab("daily")}>Défis du jour</button>
</div> </div>
{/* Overview */} {/* Overview */}
@@ -220,38 +294,77 @@ export default function AdminPage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{[ {[
{ label: "Utilisateurs", value: data.totalUsers.toLocaleString("fr-FR") }, {
{ label: "Parties totales", value: data.totalGames.toLocaleString("fr-FR") }, label: "Utilisateurs",
{ label: "Parties aujourd'hui", value: data.todayGames.toLocaleString("fr-FR") }, value: data.totalUsers.toLocaleString("fr-FR"),
},
{
label: "Parties totales",
value: data.totalGames.toLocaleString("fr-FR"),
},
{
label: "Parties aujourd'hui",
value: data.todayGames.toLocaleString("fr-FR"),
},
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className={card}> <div key={label} className={card}>
<p className="text-[10px] text-[#555] uppercase tracking-widest mb-1">{label}</p> <p className="text-[10px] text-[#555] uppercase tracking-widest mb-1">
<p className="text-3xl font-black text-[#7c3aed]">{value}</p> {label}
</p>
<p className="text-3xl font-black text-[#7c3aed]">
{value}
</p>
</div> </div>
))} ))}
</div> </div>
{/* Graphique activité */} {/* Graphique activité */}
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-4">Activité 14 derniers jours</h2> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-4">
Activité - 14 derniers jours
</h2>
<MiniBarChart data={data.activity} /> <MiniBarChart data={data.activity} />
<div className="flex justify-between mt-1"> <div className="flex justify-between mt-1">
<span className="text-[10px] text-[#555]">{new Date(data.activity[0]?.date + "T12:00:00").toLocaleDateString("fr-FR", { day: "numeric", month: "short" })}</span> <span className="text-[10px] text-[#555]">
<span className="text-[10px] text-[#555]">aujourd&apos;hui</span> {new Date(
data.activity[0]?.date + "T12:00:00",
).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
})}
</span>
<span className="text-[10px] text-[#555]">
aujourd&apos;hui
</span>
</div> </div>
</div> </div>
{/* Modes */} {/* Modes */}
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Parties par mode</h2> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Parties par mode
</h2>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.modeStats.sort((a, b) => b._count.id - a._count.id).map((m) => ( {data.modeStats
<div key={m.mode} className="flex items-center justify-between text-sm py-1.5 border-b border-[#2e2e2e] last:border-0"> .sort((a, b) => b._count.id - a._count.id)
<span className="font-semibold capitalize">{m.mode}</span> .map((m) => (
<div
key={m.mode}
className="flex items-center justify-between text-sm py-1.5 border-b border-[#2e2e2e] last:border-0"
>
<span className="font-semibold capitalize">
{m.mode}
</span>
<div className="flex items-center gap-4 text-[#888]"> <div className="flex items-center gap-4 text-[#888]">
<span>{m._count.id.toLocaleString("fr-FR")} parties</span> <span>
{m._avg.clicks !== null && <span>~{Math.round(m._avg.clicks)} clics</span>} {m._count.id.toLocaleString("fr-FR")} parties
{m._avg.timeSeconds !== null && <span>~{fmt(m._avg.timeSeconds)}</span>} </span>
{m._avg.clicks !== null && (
<span>~{Math.round(m._avg.clicks)} clics</span>
)}
{m._avg.timeSeconds !== null && (
<span>~{fmt(m._avg.timeSeconds)}</span>
)}
</div> </div>
</div> </div>
))} ))}
@@ -263,20 +376,33 @@ export default function AdminPage() {
{/* Users */} {/* Users */}
{tab === "users" && ( {tab === "users" && (
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Utilisateurs</h2> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Utilisateurs
</h2>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{data.recentUsers.map((u) => ( {data.recentUsers.map((u) => (
<div key={u.id} className={`flex items-center justify-between py-2.5 border-b border-[#2e2e2e] last:border-0 gap-3 ${u.banned ? "opacity-50" : ""}`}> <div
key={u.id}
className={`flex items-center justify-between py-2.5 border-b border-[#2e2e2e] last:border-0 gap-3 ${u.banned ? "opacity-50" : ""}`}
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate flex items-center gap-2"> <p className="font-semibold text-sm truncate flex items-center gap-2">
{u.name} {u.name}
{u.banned && <span className="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-red-900/40 text-red-400 uppercase">Banni</span>} {u.banned && (
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-red-900/40 text-red-400 uppercase">
Banni
</span>
)}
</p> </p>
<p className="text-xs text-[#555] truncate">{u.email}</p> <p className="text-xs text-[#555] truncate">{u.email}</p>
</div> </div>
<div className="text-right shrink-0 hidden sm:block"> <div className="text-right shrink-0 hidden sm:block">
<p className="text-xs text-[#888]">{u._count.games} parties</p> <p className="text-xs text-[#888]">
<p className="text-[10px] text-[#555]">{new Date(u.createdAt).toLocaleDateString("fr-FR")}</p> {u._count.games} parties
</p>
<p className="text-[10px] text-[#555]">
{new Date(u.createdAt).toLocaleDateString("fr-FR")}
</p>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<button <button
@@ -286,7 +412,13 @@ export default function AdminPage() {
{u.banned ? "Débannir" : "Bannir"} {u.banned ? "Débannir" : "Bannir"}
</button> </button>
<button <button
onClick={() => setConfirmDelete({ type: "user", id: u.id, label: u.name })} onClick={() =>
setConfirmDelete({
type: "user",
id: u.id,
label: u.name,
})
}
className="min-h-8 px-2.5 rounded-lg text-xs font-semibold bg-red-900/30 text-red-400 hover:bg-red-900/50 cursor-pointer transition-colors" className="min-h-8 px-2.5 rounded-lg text-xs font-semibold bg-red-900/30 text-red-400 hover:bg-red-900/50 cursor-pointer transition-colors"
> >
Supprimer Supprimer
@@ -301,25 +433,42 @@ export default function AdminPage() {
{/* Games */} {/* Games */}
{tab === "games" && ( {tab === "games" && (
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">50 dernières parties</h2> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
50 dernières parties
</h2>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{data.recentGames.map((g) => ( {data.recentGames.map((g) => (
<div key={g.id} className="flex items-center justify-between py-2 border-b border-[#2e2e2e] last:border-0 gap-3"> <div
key={g.id}
className="flex items-center justify-between py-2 border-b border-[#2e2e2e] last:border-0 gap-3"
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm truncate"> <p className="text-sm truncate">
<span className="font-semibold">{g.user.name}</span> <span className="font-semibold">{g.user.name}</span>
<span className="text-[#555] mx-1"></span> <span className="text-[#555] mx-1">-</span>
<span className="text-[#888] text-xs">{g.startArticle} {g.targetArticle}</span> <span className="text-[#888] text-xs">
{g.startArticle} {g.targetArticle}
</span>
</p>
<p className="text-[10px] text-[#555]">
{new Date(g.playedAt).toLocaleString("fr-FR")}
</p> </p>
<p className="text-[10px] text-[#555]">{new Date(g.playedAt).toLocaleString("fr-FR")}</p>
</div> </div>
<div className="shrink-0 flex items-center gap-2"> <div className="shrink-0 flex items-center gap-2">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${g.won ? "bg-green-900/40 text-green-400" : "bg-red-900/40 text-red-400"}`}> <span
className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${g.won ? "bg-green-900/40 text-green-400" : "bg-red-900/40 text-red-400"}`}
>
{g.won ? "Gagné" : "Perdu"} {g.won ? "Gagné" : "Perdu"}
</span> </span>
<span className="text-xs text-[#888] capitalize hidden sm:inline">{g.mode}</span> <span className="text-xs text-[#888] capitalize hidden sm:inline">
<span className="text-xs text-[#555]">{g.clicks} clics</span> {g.mode}
<span className="text-xs text-[#555] hidden sm:inline">{fmt(g.timeSeconds)}</span> </span>
<span className="text-xs text-[#555]">
{g.clicks} clics
</span>
<span className="text-xs text-[#555] hidden sm:inline">
{fmt(g.timeSeconds)}
</span>
</div> </div>
</div> </div>
))} ))}
@@ -333,7 +482,9 @@ export default function AdminPage() {
{/* Formulaire */} {/* Formulaire */}
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3"> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
{editingPuzzle ? `Modifier le puzzle du ${editingPuzzle.date}` : "Nouveau puzzle"} {editingPuzzle
? `Modifier le puzzle du ${editingPuzzle.date}`
: "Nouveau puzzle"}
</h2> </h2>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{!editingPuzzle && ( {!editingPuzzle && (
@@ -341,7 +492,9 @@ export default function AdminPage() {
className={inputCls} className={inputCls}
type="date" type="date"
value={puzzleForm.date} value={puzzleForm.date}
onChange={(e) => setPuzzleForm((f) => ({ ...f, date: e.target.value }))} onChange={(e) =>
setPuzzleForm((f) => ({ ...f, date: e.target.value }))
}
placeholder="Date (YYYY-MM-DD)" placeholder="Date (YYYY-MM-DD)"
/> />
)} )}
@@ -349,27 +502,53 @@ export default function AdminPage() {
className={inputCls} className={inputCls}
type="text" type="text"
value={puzzleForm.startArticle} value={puzzleForm.startArticle}
onChange={(e) => setPuzzleForm((f) => ({ ...f, startArticle: e.target.value }))} onChange={(e) =>
setPuzzleForm((f) => ({
...f,
startArticle: e.target.value,
}))
}
placeholder="Article de départ (ex: Tour Eiffel)" placeholder="Article de départ (ex: Tour Eiffel)"
/> />
<input <input
className={inputCls} className={inputCls}
type="text" type="text"
value={puzzleForm.targetArticle} value={puzzleForm.targetArticle}
onChange={(e) => setPuzzleForm((f) => ({ ...f, targetArticle: e.target.value }))} onChange={(e) =>
setPuzzleForm((f) => ({
...f,
targetArticle: e.target.value,
}))
}
placeholder="Article cible (ex: Napoléon Bonaparte)" placeholder="Article cible (ex: Napoléon Bonaparte)"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleSavePuzzle} onClick={handleSavePuzzle}
disabled={puzzleSaving || (!editingPuzzle && !puzzleForm.date) || !puzzleForm.startArticle || !puzzleForm.targetArticle} disabled={
puzzleSaving ||
(!editingPuzzle && !puzzleForm.date) ||
!puzzleForm.startArticle ||
!puzzleForm.targetArticle
}
className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-40 cursor-pointer transition-colors" className="flex-1 min-h-10 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-40 cursor-pointer transition-colors"
> >
{puzzleSaving ? "Enregistrement..." : editingPuzzle ? "Modifier" : "Créer"} {puzzleSaving
? "Enregistrement..."
: editingPuzzle
? "Modifier"
: "Créer"}
</button> </button>
{editingPuzzle && ( {editingPuzzle && (
<button <button
onClick={() => { setEditingPuzzle(null); setPuzzleForm({ date: "", startArticle: "", targetArticle: "" }); }} onClick={() => {
setEditingPuzzle(null);
setPuzzleForm({
date: "",
startArticle: "",
targetArticle: "",
});
}}
className="min-h-10 px-4 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#888] cursor-pointer hover:text-[#f0f0f0]" className="min-h-10 px-4 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#888] cursor-pointer hover:text-[#f0f0f0]"
> >
Annuler Annuler
@@ -382,14 +561,24 @@ export default function AdminPage() {
{/* Liste */} {/* Liste */}
{dailyPuzzles && ( {dailyPuzzles && (
<div className={card}> <div className={card}>
<h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Puzzles existants</h2> <h2 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Puzzles existants
</h2>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{dailyPuzzles.map((p) => ( {dailyPuzzles.map((p) => (
<div key={p.id} className="flex items-center justify-between py-2.5 border-b border-[#2e2e2e] last:border-0 gap-3"> <div
key={p.id}
className="flex items-center justify-between py-2.5 border-b border-[#2e2e2e] last:border-0 gap-3"
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{p.date}</p> <p className="text-sm font-semibold">{p.date}</p>
<p className="text-xs text-[#888] truncate">{p.startArticle} {p.targetArticle}</p> <p className="text-xs text-[#888] truncate">
<p className="text-[10px] text-[#555]">{p._count.results} résultat{p._count.results > 1 ? "s" : ""}</p> {p.startArticle} {p.targetArticle}
</p>
<p className="text-[10px] text-[#555]">
{p._count.results} résultat
{p._count.results > 1 ? "s" : ""}
</p>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<button <button
@@ -399,7 +588,13 @@ export default function AdminPage() {
Modifier Modifier
</button> </button>
<button <button
onClick={() => setConfirmDelete({ type: "puzzle", id: p.id, label: `puzzle du ${p.date}` })} onClick={() =>
setConfirmDelete({
type: "puzzle",
id: p.id,
label: `puzzle du ${p.date}`,
})
}
className="min-h-8 px-2.5 rounded-lg text-xs font-semibold bg-red-900/30 text-red-400 hover:bg-red-900/50 cursor-pointer" className="min-h-8 px-2.5 rounded-lg text-xs font-semibold bg-red-900/30 text-red-400 hover:bg-red-900/50 cursor-pointer"
> >
Supprimer Supprimer
+67 -22
View File
@@ -3,29 +3,59 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
const FORBIDDEN_NAMESPACES = [ const FORBIDDEN_NAMESPACES = [
"Fichier:", "File:", "Wikipedia:", "Aide:", "Help:", "Categorie:", "Category:", "Fichier:",
"Discussion:", "Talk:", "Utilisateur:", "User:", "Special:", "Sp\u00e9cial:", "File:",
"Portail:", "Portal:", "Mod\u00e8le:", "Template:", "Projet:", "WP:", "Wikipedia:",
"Aide:",
"Help:",
"Categorie:",
"Category:",
"Discussion:",
"Talk:",
"Utilisateur:",
"User:",
"Special:",
"Sp\u00e9cial:",
"Portail:",
"Portal:",
"Mod\u00e8le:",
"Template:",
"Projet:",
"WP:",
]; ];
const REMOVED_SECTION_IDS = [ const REMOVED_SECTION_IDS = [
"Liens_externes", "R\u00e9f\u00e9rences", "Notes", "Bibliographie", "Liens_externes",
"Voir_aussi", "Notes_et_r\u00e9f\u00e9rences", "Sources", "R\u00e9f\u00e9rences",
"Annexes", "Articles_connexes", "Notes",
"Bibliographie",
"Voir_aussi",
"Notes_et_r\u00e9f\u00e9rences",
"Sources",
"Annexes",
"Articles_connexes",
]; ];
function cleanWikiHtml(container: HTMLElement): void { function cleanWikiHtml(container: HTMLElement): void {
container.querySelectorAll(".mw-editsection").forEach((el) => el.remove()); container.querySelectorAll(".mw-editsection").forEach((el) => el.remove());
container.querySelectorAll( container
".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference" .querySelectorAll(
).forEach((el) => el.remove()); ".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference",
container.querySelectorAll( )
".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail" .forEach((el) => el.remove());
).forEach((el) => el.remove()); container
container.querySelectorAll( .querySelectorAll(
".ambox, .tmbox, .cmbox, .ombox, .fmbox, .hatnote, .bandeau-container, .bandeau" ".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail",
).forEach((el) => el.remove()); )
container.querySelectorAll(".audio, .audiolink, audio, video").forEach((el) => el.remove()); .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(".gallery").forEach((el) => el.remove());
container.querySelectorAll("#toc, .toc").forEach((el) => el.remove()); container.querySelectorAll("#toc, .toc").forEach((el) => el.remove());
@@ -68,8 +98,12 @@ export function ArticleView({
const onNavigateRef = useRef(onNavigate); const onNavigateRef = useRef(onNavigate);
const disabledRef = useRef(disabled); const disabledRef = useRef(disabled);
useEffect(() => { onNavigateRef.current = onNavigate; }); useEffect(() => {
useEffect(() => { disabledRef.current = disabled; }); onNavigateRef.current = onNavigate;
});
useEffect(() => {
disabledRef.current = disabled;
});
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -78,14 +112,23 @@ export function ArticleView({
container.innerHTML = html; container.innerHTML = html;
cleanWikiHtml(container); cleanWikiHtml(container);
container.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']").forEach((link) => { container
.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']")
.forEach((link) => {
const href = link.getAttribute("href") ?? ""; const href = link.getAttribute("href") ?? "";
const path = href.replace("/wiki/", ""); const path = href.replace("/wiki/", "");
let decoded: string; let decoded: string;
try { decoded = decodeURIComponent(path); } catch { decoded = path; } try {
decoded = decodeURIComponent(path);
} catch {
decoded = path;
}
const title = decoded.replace(/_/g, " "); const title = decoded.replace(/_/g, " ");
if (FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) || title.includes("#")) { if (
FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) ||
title.includes("#")
) {
link.removeAttribute("href"); link.removeAttribute("href");
return; return;
} }
@@ -100,7 +143,9 @@ export function ArticleView({
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (disabledRef.current) return; if (disabledRef.current) return;
const target = (e.target as HTMLElement).closest("[data-wiki-title]") as HTMLElement | null; const target = (e.target as HTMLElement).closest(
"[data-wiki-title]",
) as HTMLElement | null;
if (!target) return; if (!target) return;
e.preventDefault(); e.preventDefault();
const title = target.getAttribute("data-wiki-title"); const title = target.getAttribute("data-wiki-title");
+87 -18
View File
@@ -5,7 +5,13 @@ import { signIn } from "next-auth/react";
type Mode = "login" | "register"; type Mode = "login" | "register";
export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { export function AuthModal({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess: () => void;
}) {
const [mode, setMode] = useState<Mode>("login"); const [mode, setMode] = useState<Mode>("login");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -24,30 +30,56 @@ export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSucce
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }), body: JSON.stringify({ name, email, password }),
}); });
const data = await res.json() as { error?: string }; const data = (await res.json()) as { error?: string };
if (!res.ok) { setError(data.error ?? "Erreur"); return; } 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;
} }
const result = await signIn("credentials", { email, password, redirect: false });
if (result?.error) { setError("Email ou mot de passe incorrect"); return; }
onSuccess(); onSuccess();
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
const inputCls = "w-full min-h-11 px-3.5 bg-[#0f0f0f] border-[1.5px] border-[#2e2e2e] rounded-xl text-[#f0f0f0] text-sm outline-none focus:border-[#7c3aed]"; const inputCls =
"w-full min-h-11 px-3.5 bg-[#0f0f0f] border-[1.5px] border-[#2e2e2e] rounded-xl text-[#f0f0f0] text-sm outline-none focus:border-[#7c3aed]";
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-1000 p-4" onClick={onClose}> <div
<div className="relative bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-8 w-full max-w-96 animate-fade-in" onClick={(e) => e.stopPropagation()}> className="fixed inset-0 bg-black/70 flex items-center justify-center z-1000 p-4"
<button className="absolute top-3 right-3.5 text-[#888] hover:text-[#f0f0f0] text-lg leading-none px-1.5 py-1 cursor-pointer bg-transparent border-none" onClick={onClose} aria-label="Fermer"></button> onClick={onClose}
>
<div
className="relative bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-8 w-full max-w-96 animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<button
className="absolute top-3 right-3.5 text-[#888] hover:text-[#f0f0f0] text-lg leading-none px-1.5 py-1 cursor-pointer bg-transparent border-none"
onClick={onClose}
aria-label="Fermer"
>
</button>
<div className="flex border-b border-[#2e2e2e] mb-6"> <div className="flex border-b border-[#2e2e2e] mb-6">
{(["login", "register"] as Mode[]).map((m) => ( {(["login", "register"] as Mode[]).map((m) => (
<button <button
key={m} key={m}
className={`flex-1 text-sm font-semibold py-2.5 border-b-2 -mb-px cursor-pointer bg-transparent transition-colors ${mode === m ? "text-[#7c3aed] border-[#7c3aed]" : "text-[#888] border-transparent hover:text-[#f0f0f0]"}`} className={`flex-1 text-sm font-semibold py-2.5 border-b-2 -mb-px cursor-pointer bg-transparent transition-colors ${mode === m ? "text-[#7c3aed] border-[#7c3aed]" : "text-[#888] border-transparent hover:text-[#f0f0f0]"}`}
onClick={() => { setMode(m); setError(null); }} onClick={() => {
setMode(m);
setError(null);
}}
> >
{m === "login" ? "Connexion" : "Inscription"} {m === "login" ? "Connexion" : "Inscription"}
</button> </button>
@@ -56,24 +88,61 @@ export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSucce
<form className="flex flex-col gap-3" onSubmit={handleSubmit}> <form className="flex flex-col gap-3" onSubmit={handleSubmit}>
{mode === "register" && ( {mode === "register" && (
<input className={inputCls} type="text" placeholder="Pseudo" value={name} onChange={(e) => setName(e.target.value)} required maxLength={30} /> <input
className={inputCls}
type="text"
placeholder="Pseudo"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={30}
/>
)} )}
<input className={inputCls} type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <input
<input className={inputCls} type="password" placeholder="Mot de passe" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} /> className={inputCls}
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
className={inputCls}
type="password"
placeholder="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
{error && ( {error && (
<div className="bg-red-950/40 border border-red-600 text-red-300 px-3 py-2 rounded-xl text-sm">{error}</div> <div className="bg-red-950/40 border border-red-600 text-red-300 px-3 py-2 rounded-xl text-sm">
{error}
</div>
)} )}
<button className="w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer mt-1" type="submit" disabled={loading}> <button
{loading ? "Chargement..." : mode === "login" ? "Se connecter" : "Créer un compte"} className="w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer mt-1"
type="submit"
disabled={loading}
>
{loading
? "Chargement..."
: mode === "login"
? "Se connecter"
: "Créer un compte"}
</button> </button>
{mode === "register" && ( {mode === "register" && (
<p className="text-xs text-[#555] text-center leading-relaxed"> <p className="text-xs text-[#555] text-center leading-relaxed">
En créant un compte, vous acceptez notre{" "} En créant un compte, vous acceptez notre{" "}
<a href="/privacy" className="text-[#888] hover:text-[#f0f0f0] underline underline-offset-2 transition-colors"> <a
href="/privacy"
className="text-[#888] hover:text-[#f0f0f0] underline underline-offset-2 transition-colors"
>
politique de confidentialité politique de confidentialité
</a>. </a>
.
</p> </p>
)} )}
</form> </form>
+115 -32
View File
@@ -15,60 +15,85 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
const game = useBlitzGame(); const game = useBlitzGame();
const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed(); const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed();
const btnPrimary =
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"; "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 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; const danger = game.timeLeft < 30;
if (game.phase === "setup") return ( 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"> <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}> <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 Retour
</button> </button>
<div className="text-5xl"></div> <div className="text-5xl"></div>
<h2 className="text-2xl sm:text-3xl font-black">Mode Blitz</h2> <h2 className="text-2xl sm:text-3xl font-black">Mode Blitz</h2>
<p className="text-[#888] text-sm sm:text-base leading-relaxed"> <p className="text-[#888] text-sm sm:text-base leading-relaxed">
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 ! 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> </p>
<button <button
onClick={toggleSearch} onClick={toggleSearch}
className={`w-full min-h-11 rounded-xl text-sm font-semibold border transition-colors cursor-pointer flex items-center justify-between px-4 ${searchAllowed ? "bg-[#7c3aed]/10 border-[#7c3aed] text-[#a78bfa]" : "bg-[#1a1a1a] border-[#2e2e2e] text-[#888]"}`} className={`w-full min-h-11 rounded-xl text-sm font-semibold border transition-colors cursor-pointer flex items-center justify-between px-4 ${searchAllowed ? "bg-[#7c3aed]/10 border-[#7c3aed] text-[#a78bfa]" : "bg-[#1a1a1a] border-[#2e2e2e] text-[#888]"}`}
> >
<span>🔍 Recherche Ctrl+F</span> <span>🔍 Recherche Ctrl+F</span>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${searchAllowed ? "bg-[#7c3aed]/30 text-[#a78bfa]" : "bg-[#242424] text-[#555]"}`}> <span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${searchAllowed ? "bg-[#7c3aed]/30 text-[#a78bfa]" : "bg-[#242424] text-[#555]"}`}
>
{searchAllowed ? "Autorisée" : "Bloquée"} {searchAllowed ? "Autorisée" : "Bloquée"}
</span> </span>
</button> </button>
<button className={btnPrimary} onClick={game.start} disabled={game.loading}> <button
className={btnPrimary}
onClick={game.start}
disabled={game.loading}
>
{game.loading ? "Préparation..." : "Lancer le chrono !"} {game.loading ? "Préparation..." : "Lancer le chrono !"}
</button> </button>
</div> </div>
); );
if (game.phase === "won") 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="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="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> <div className="text-5xl sm:text-6xl leading-none">🎉</div>
<h2 className="text-2xl sm:text-3xl font-black">Article trouvé !</h2> <h2 className="text-2xl sm:text-3xl font-black">Article trouvé !</h2>
{game.puzzle && ( {game.puzzle && (
<p className="text-[#888] text-sm"> <p className="text-[#888] text-sm">
<span className="text-[#f0f0f0] font-semibold">{game.puzzle.start}</span> <span className="text-[#f0f0f0] font-semibold">
{game.puzzle.start}
</span>
{" → "} {" → "}
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span> <span className="text-[#7c3aed] font-semibold">
{game.puzzle.target}
</span>
</p> </p>
)} )}
<div className="flex gap-8 justify-center"> <div className="flex gap-8 justify-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{fmtLeft(game.timeLeft)}</span> <span className="text-4xl font-black text-[#7c3aed]">
{fmtLeft(game.timeLeft)}
</span>
<span className="text-xs text-[#888]">temps restant</span> <span className="text-xs text-[#888]">temps restant</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{game.clicks}</span> <span className="text-4xl font-black text-[#7c3aed]">
{game.clicks}
</span>
<span className="text-xs text-[#888]">clics</span> <span className="text-xs text-[#888]">clics</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-4xl font-black text-[#7c3aed]">{game.history.length}</span> <span className="text-4xl font-black text-[#7c3aed]">
{game.history.length}
</span>
<span className="text-xs text-[#888]">articles</span> <span className="text-xs text-[#888]">articles</span>
</div> </div>
</div> </div>
@@ -76,22 +101,43 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
{game.history.map((t, i) => ( {game.history.map((t, i) => (
<span key={i} className="flex items-center gap-1"> <span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-[#555]"></span>} {i > 0 && <span className="text-[#555]"></span>}
<span className={t === game.puzzle?.target ? "text-[#7c3aed] font-bold" : ""}>{t}</span> <span
className={
t === game.puzzle?.target ? "text-[#7c3aed] font-bold" : ""
}
>
{t}
</span>
</span> </span>
))} ))}
</div> </div>
<ShareBar text={`⚡ Mode Blitz WikiRush : j'ai atteint "${game.puzzle?.target}" en ${game.clicks} clics avec ${fmtLeft(game.timeLeft)} restant !`} /> <ShareBar
text={`⚡ Mode Blitz WikiRush : j'ai atteint "${game.puzzle?.target}" en ${game.clicks} clics avec ${fmtLeft(game.timeLeft)} restant !`}
/>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<button className={btnPrimary} onClick={game.start} disabled={game.loading}> <button
className={btnPrimary}
onClick={game.start}
disabled={game.loading}
>
Rejouer Rejouer
</button> </button>
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button> <button
className={btnGhost}
onClick={() => {
game.reset();
onBack();
}}
>
Accueil
</button>
</div> </div>
</div> </div>
</div> </div>
); );
if (game.phase === "lost") return ( 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="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="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> <div className="text-5xl sm:text-6xl leading-none"></div>
@@ -99,16 +145,22 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
{game.puzzle && ( {game.puzzle && (
<p className="text-[#888] text-sm"> <p className="text-[#888] text-sm">
L&apos;objectif était d&apos;atteindre{" "} L&apos;objectif était d&apos;atteindre{" "}
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span> <span className="text-[#7c3aed] font-semibold">
{game.puzzle.target}
</span>
</p> </p>
)} )}
<div className="flex gap-8 justify-center"> <div className="flex gap-8 justify-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-4xl font-black text-red-400">{game.clicks}</span> <span className="text-4xl font-black text-red-400">
{game.clicks}
</span>
<span className="text-xs text-[#888]">clics</span> <span className="text-xs text-[#888]">clics</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-4xl font-black text-red-400">{game.history.length}</span> <span className="text-4xl font-black text-red-400">
{game.history.length}
</span>
<span className="text-xs text-[#888]">articles visités</span> <span className="text-xs text-[#888]">articles visités</span>
</div> </div>
</div> </div>
@@ -123,10 +175,22 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
</div> </div>
)} )}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<button className={btnPrimary} onClick={game.start} disabled={game.loading}> <button
className={btnPrimary}
onClick={game.start}
disabled={game.loading}
>
Réessayer Réessayer
</button> </button>
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button> <button
className={btnGhost}
onClick={() => {
game.reset();
onBack();
}}
>
Accueil
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -139,18 +203,27 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
<div className="article-container"> <div className="article-container">
{game.title && <h1 className="article-title">{game.title}</h1>} {game.title && <h1 className="article-title">{game.title}</h1>}
{game.loading && ( {game.loading && (
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div> <div className="flex items-center gap-3 py-10 px-4 text-[#888]">
<div className="spinner" /> Chargement...
</div>
)} )}
{game.loadError && ( {game.loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center"> <div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{game.loadError}</p> <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()}> <button
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer"
onClick={() => game.retryLoad()}
>
Réessayer Réessayer
</button> </button>
</div> </div>
)} )}
{!game.loading && !game.loadError && game.html && ( {!game.loading && !game.loadError && game.html && (
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} /> <ArticleView
html={game.html}
onNavigate={game.navigate}
disabled={game.loading}
/>
)} )}
</div> </div>
</div> </div>
@@ -160,20 +233,30 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex flex-col items-center gap-0.5"> <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-[8px] font-bold uppercase tracking-wider text-[#888]">
<span className="text-sm font-black tabular-nums">{game.clicks}</span> Clics
</span>
<span className="text-sm font-black tabular-nums">
{game.clicks}
</span>
</div> </div>
</div> </div>
{/* Timer */} {/* Timer */}
<div className={`text-2xl sm:text-3xl font-black tabular-nums transition-colors ${danger ? "text-red-400 animate-pulse" : "text-[#f0f0f0]"}`}> <div
className={`text-2xl sm:text-3xl font-black tabular-nums transition-colors ${danger ? "text-red-400 animate-pulse" : "text-[#f0f0f0]"}`}
>
{fmtLeft(game.timeLeft)} {fmtLeft(game.timeLeft)}
</div> </div>
{game.puzzle && ( {game.puzzle && (
<div className="text-right"> <div className="text-right">
<div className="text-[8px] font-bold uppercase tracking-wider text-[#888]">Cible</div> <div className="text-[8px] font-bold uppercase tracking-wider text-[#888]">
<div className="text-xs font-bold text-[#7c3aed] max-w-28 truncate">{game.puzzle.target}</div> Cible
</div>
<div className="text-xs font-bold text-[#7c3aed] max-w-28 truncate">
{game.puzzle.target}
</div>
</div> </div>
)} )}
</div> </div>
+14 -2
View File
@@ -2,13 +2,25 @@
import { useRef } from "react"; import { useRef } from "react";
export function Breadcrumbs({ history, endRef }: { history: string[]; endRef: React.RefObject<HTMLDivElement | null> }) { export function Breadcrumbs({
history,
endRef,
}: {
history: string[];
endRef: React.RefObject<HTMLDivElement | null>;
}) {
return ( return (
<div className="flex items-center flex-nowrap overflow-x-auto gap-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex items-center flex-nowrap overflow-x-auto gap-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{history.map((title, i) => ( {history.map((title, i) => (
<span key={i} className="flex items-center whitespace-nowrap shrink-0"> <span key={i} className="flex items-center whitespace-nowrap shrink-0">
{i > 0 && <span className="text-[#888] text-xs px-1"></span>} {i > 0 && <span className="text-[#888] text-xs px-1"></span>}
<span className={i === history.length - 1 ? "text-xs font-bold text-[#f0f0f0]" : "text-xs text-[#888]"}> <span
className={
i === history.length - 1
? "text-xs font-bold text-[#f0f0f0]"
: "text-xs text-[#888]"
}
>
{title} {title}
</span> </span>
</span> </span>
+138 -36
View File
@@ -9,16 +9,32 @@ import { ShareBar } from "./ShareBar";
const MEDALS = ["🥇", "🥈", "🥉"]; const MEDALS = ["🥇", "🥈", "🥉"];
type LeaderboardEntry = { rank: number; name: string; clicks: number; timeSeconds: number; userId: string }; type LeaderboardEntry = {
rank: number;
name: string;
clicks: number;
timeSeconds: number;
userId: string;
};
export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; currentUserId?: string }) { export function DailyScreen({
onBack,
currentUserId,
}: {
onBack: () => void;
currentUserId?: string;
}) {
const game = useDailyGame(); const game = useDailyGame();
const breadcrumbEndRef = useRef<HTMLDivElement>(null); const breadcrumbEndRef = useRef<HTMLDivElement>(null);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]); const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loadingLb, setLoadingLb] = useState(false); const [loadingLb, setLoadingLb] = useState(false);
useEffect(() => { useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" }); breadcrumbEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "end",
});
}, [game.history]); }, [game.history]);
function fetchLeaderboard() { function fetchLeaderboard() {
@@ -30,47 +46,78 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
} }
useEffect(() => { useEffect(() => {
if (game.phase === "won" || game.phase === "gave_up" || game.phase === "already_played") { if (
game.phase === "won" ||
game.phase === "gave_up" ||
game.phase === "already_played"
) {
fetchLeaderboard(); fetchLeaderboard();
} }
}, [game.phase]); }, [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 btnPrimary =
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"; "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 // Loading initial
if (game.phase === "loading") return ( if (game.phase === "loading")
return (
<div className="min-h-dvh bg-[#0f0f0f] flex items-center justify-center"> <div className="min-h-dvh bg-[#0f0f0f] flex items-center justify-center">
<div className="spinner" /> <div className="spinner" />
</div> </div>
); );
// Erreur initiale // Erreur initiale
if (game.loadError) return ( if (game.loadError)
return (
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] flex flex-col items-center justify-center gap-4 px-4"> <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> <p className="text-[#888]">{game.loadError}</p>
<button className={btnGhost} style={{ width: "auto", padding: "0 20px" }} onClick={onBack}>Retour</button> <button
className={btnGhost}
style={{ width: "auto", padding: "0 20px" }}
onClick={onBack}
>
Retour
</button>
</div> </div>
); );
// Résultat (won / gave_up / already_played) // Résultat (won / gave_up / already_played)
if (game.phase === "won" || game.phase === "gave_up" || game.phase === "already_played") { if (
game.phase === "won" ||
game.phase === "gave_up" ||
game.phase === "already_played"
) {
const result = game.myResult; const result = game.myResult;
return ( 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"> <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}> <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 Retour
</button> </button>
<div className="text-center"> <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"> <div className="text-2xl sm:text-3xl font-black mb-1">
{game.phase === "won" ? "🎉 Réussi !" : game.phase === "gave_up" ? "😔 Abandonné" : "✅ Déjà joué"} {game.phase === "won"
? "🎉 Réussi !"
: game.phase === "gave_up"
? "😔 Abandonné"
: "✅ Déjà joué"}
</div> </div>
<div className="text-sm text-[#888]"> <div className="text-sm text-[#888]">
<span className="text-[#f0f0f0] font-semibold">{game.puzzle?.startArticle}</span> <span className="text-[#f0f0f0] font-semibold">
{game.puzzle?.startArticle}
</span>
<span className="mx-2"></span> <span className="mx-2"></span>
<span className="text-[#7c3aed] font-semibold">{game.puzzle?.targetArticle}</span> <span className="text-[#7c3aed] font-semibold">
{game.puzzle?.targetArticle}
</span>
</div> </div>
</div> </div>
@@ -78,11 +125,15 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-3"> <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="flex justify-center gap-8">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-black text-[#7c3aed]">{result.clicks}</div> <div className="text-3xl font-black text-[#7c3aed]">
{result.clicks}
</div>
<div className="text-xs text-[#888]">clics</div> <div className="text-xs text-[#888]">clics</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-black text-[#7c3aed]">{fmt(result.timeSeconds)}</div> <div className="text-3xl font-black text-[#7c3aed]">
{fmt(result.timeSeconds)}
</div>
<div className="text-xs text-[#888]">temps</div> <div className="text-xs text-[#888]">temps</div>
</div> </div>
</div> </div>
@@ -91,7 +142,15 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
{result.path.map((t, i) => ( {result.path.map((t, i) => (
<span key={i} className="flex items-center gap-0.5"> <span key={i} className="flex items-center gap-0.5">
{i > 0 && <span className="text-[#555]"></span>} {i > 0 && <span className="text-[#555]"></span>}
<span className={i === result.path.length - 1 && result.won ? "text-[#16a34a] font-semibold" : ""}>{t}</span> <span
className={
i === result.path.length - 1 && result.won
? "text-[#16a34a] font-semibold"
: ""
}
>
{t}
</span>
</span> </span>
))} ))}
</div> </div>
@@ -101,18 +160,33 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
{/* Classement du jour */} {/* Classement du jour */}
<div> <div>
<h3 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Classement du jour</h3> <h3 className="text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Classement du jour
</h3>
{loadingLb ? ( {loadingLb ? (
<div className="flex justify-center py-6"><div className="spinner" /></div> <div className="flex justify-center py-6">
<div className="spinner" />
</div>
) : leaderboard.length === 0 ? ( ) : leaderboard.length === 0 ? (
<p className="text-sm text-[#888] text-center py-4">Aucun résultat pour l&apos;instant.</p> <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"> <div className="flex flex-col gap-2">
{leaderboard.map((entry, i) => ( {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]"}`}> <div
<span className="text-lg w-7 text-center shrink-0">{MEDALS[i] ?? `#${entry.rank}`}</span> key={i}
<span className="flex-1 font-semibold text-sm truncate">{entry.name}</span> 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-xs text-[#888] tabular-nums shrink-0">{entry.clicks} clics · {fmt(entry.timeSeconds)}</span> >
<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> </div>
@@ -120,9 +194,13 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
</div> </div>
{game.phase === "won" && result && ( {game.phase === "won" && result && (
<ShareBar text={`🗓 Défi du jour WikiRush : j'ai atteint "${game.puzzle?.targetArticle}" en ${result.clicks} clics et ${fmt(result.timeSeconds)} !`} /> <ShareBar
text={`🗓 Défi du jour WikiRush : j'ai atteint "${game.puzzle?.targetArticle}" en ${result.clicks} clics et ${fmt(result.timeSeconds)} !`}
/>
)} )}
<button className={btnGhost} onClick={onBack}>Retour à l&apos;accueil</button> <button className={btnGhost} onClick={onBack}>
Retour à l&apos;accueil
</button>
</div> </div>
); );
} }
@@ -134,18 +212,27 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
<div className="article-container"> <div className="article-container">
{game.title && <h1 className="article-title">{game.title}</h1>} {game.title && <h1 className="article-title">{game.title}</h1>}
{game.loading && ( {game.loading && (
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div> <div className="flex items-center gap-3 py-10 px-4 text-[#888]">
<div className="spinner" /> Chargement...
</div>
)} )}
{game.loadError && ( {game.loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center"> <div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{game.loadError}</p> <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()}> <button
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer"
onClick={() => game.retryLoad()}
>
Réessayer Réessayer
</button> </button>
</div> </div>
)} )}
{!game.loading && !game.loadError && game.html && ( {!game.loading && !game.loadError && game.html && (
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} /> <ArticleView
html={game.html}
onNavigate={game.navigate}
disabled={game.loading}
/>
)} )}
</div> </div>
</div> </div>
@@ -154,22 +241,37 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
<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="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-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-1.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-[8px] font-bold uppercase tracking-widest text-[#888] shrink-0">
<span className="text-xs font-black text-[#7c3aed] truncate">{game.puzzle?.targetArticle}</span> 🗓 Défi du jour · cible
</span>
<span className="text-xs font-black text-[#7c3aed] truncate">
{game.puzzle?.targetArticle}
</span>
</div> </div>
<Breadcrumbs history={game.history} endRef={breadcrumbEndRef} /> <Breadcrumbs history={game.history} endRef={breadcrumbEndRef} />
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<div className="flex flex-col items-center gap-0.5 min-w-10"> <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-[8px] font-bold tracking-wider text-[#888] uppercase">
<span className="text-xs font-black tabular-nums">{game.clicks}</span> Clics
</span>
<span className="text-xs font-black tabular-nums">
{game.clicks}
</span>
</div> </div>
{game.canGoBack && ( {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}> <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 +1
</button> </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}> <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 Abandonner
</button> </button>
</div> </div>
+166 -51
View File
@@ -25,8 +25,21 @@ type GameScreenProps = {
}; };
export function GameScreen({ export function GameScreen({
room, playerId, html, title, loading, loadError, history, clicks, elapsed, room,
countdown, onNavigate, onRetry, onNextRound, onResetGame, onSurrender, playerId,
html,
title,
loading,
loadError,
history,
clicks,
elapsed,
countdown,
onNavigate,
onRetry,
onNextRound,
onResetGame,
onSurrender,
}: GameScreenProps) { }: GameScreenProps) {
const breadcrumbEndRef = useRef<HTMLDivElement>(null); const breadcrumbEndRef = useRef<HTMLDivElement>(null);
useCtrlFBlock(room.searchAllowed); useCtrlFBlock(room.searchAllowed);
@@ -34,60 +47,104 @@ export function GameScreen({
const isHost = myPlayer?.isHost ?? false; const isHost = myPlayer?.isHost ?? false;
const myFinished = myPlayer?.hasWon || myPlayer?.hasSurrendered; const myFinished = myPlayer?.hasWon || myPlayer?.hasSurrendered;
const sortedPlayers = [...room.players].sort((a, b) => b.score - a.score); const sortedPlayers = [...room.players].sort((a, b) => b.score - a.score);
const winner = room.roundWinner ? room.players.find((p) => p.id === room.roundWinner) : null; const winner = room.roundWinner
? room.players.find((p) => p.id === room.roundWinner)
: null;
useEffect(() => { useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" }); breadcrumbEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "end",
});
}, [history]); }, [history]);
const btnPrimary = "w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] cursor-pointer transition-colors"; const btnPrimary =
const btnGhost = "w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors"; "w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] cursor-pointer transition-colors";
const btnGhost =
"w-full min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
// Results // Results
if (room.phase === "results") return ( if (room.phase === "results")
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8"> <div className="min-h-dvh w-full 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 flex flex-col gap-5 sm:gap-6"> <div className="w-full max-w-sm sm:max-w-md flex flex-col gap-5 sm:gap-6">
<div className="text-center bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-5 sm:p-6"> <div className="text-center bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-5 sm:p-6">
{winner ? ( {winner ? (
<> <>
<div className="text-4xl sm:text-5xl mb-2">🏆</div> <div className="text-4xl sm:text-5xl mb-2">🏆</div>
<div className="text-base sm:text-lg font-bold">{winner.name} a gagné la manche !</div> <div className="text-base sm:text-lg font-bold">
{winner.name} a gagné la manche !
</div>
</> </>
) : ( ) : (
<div className="text-base sm:text-lg font-bold">Manche terminée !</div> <div className="text-base sm:text-lg font-bold">
Manche terminée !
</div>
)} )}
</div> </div>
<div className="text-center text-xs sm:text-sm text-[#888]"> <div className="text-center text-xs sm:text-sm text-[#888]">
<span className="text-[#f0f0f0]">{room.startArticle}</span> <span className="text-[#f0f0f0]">{room.startArticle}</span>
<span className="mx-2"></span> <span className="mx-2"></span>
<span className="text-[#7c3aed] font-bold">{room.targetArticle}</span> <span className="text-[#7c3aed] font-bold">
{room.targetArticle}
</span>
</div> </div>
<div> <div>
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider mb-3">Classement</h3> <h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Classement
</h3>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{sortedPlayers.map((p, i) => ( {sortedPlayers.map((p, i) => (
<li key={p.id} className={`flex flex-col gap-2 bg-[#1a1a1a] rounded-xl px-3.5 py-2.5 border ${p.id === playerId ? "border-[#7c3aed]" : "border-[#2e2e2e]"}`}> <li
key={p.id}
className={`flex flex-col gap-2 bg-[#1a1a1a] rounded-xl px-3.5 py-2.5 border ${p.id === playerId ? "border-[#7c3aed]" : "border-[#2e2e2e]"}`}
>
<div className="flex items-center gap-2 min-h-7"> <div className="flex items-center gap-2 min-h-7">
<span className="text-xs sm:text-sm font-bold text-[#888] min-w-6 sm:min-w-7">#{i + 1}</span> <span className="text-xs sm:text-sm font-bold text-[#888] min-w-6 sm:min-w-7">
<span className="flex-1 font-semibold text-sm truncate">{p.name}</span> #{i + 1}
{p.hasWon && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-green-900/40 text-green-300 uppercase tracking-wide shrink-0"> Trouvé</span>} </span>
{p.hasSurrendered && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#242424] text-[#888] uppercase tracking-wide shrink-0">Forfait</span>} <span className="flex-1 font-semibold text-sm truncate">
<span className="font-bold text-[#7c3aed] text-sm shrink-0">{p.score} pts</span> {p.name}
</span>
{p.hasWon && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-green-900/40 text-green-300 uppercase tracking-wide shrink-0">
Trouvé
</span>
)}
{p.hasSurrendered && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#242424] text-[#888] uppercase tracking-wide shrink-0">
Forfait
</span>
)}
<span className="font-bold text-[#7c3aed] text-sm shrink-0">
{p.score} pts
</span>
</div> </div>
{p.path && p.path.length > 0 && ( {p.path && p.path.length > 0 && (
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-[#666] pl-6 sm:pl-7"> <div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[11px] text-[#666] pl-6 sm:pl-7">
{p.path.map((t, j) => ( {p.path.map((t, j) => (
<span key={j} className="flex items-center gap-0.5"> <span key={j} className="flex items-center gap-0.5">
{j > 0 && <span className="text-[#444]"></span>} {j > 0 && <span className="text-[#444]"></span>}
<span className={ <span
j === 0 ? "text-[#555]" : className={
j === p.path.length - 1 && p.hasWon ? "text-green-400 font-semibold" : j === 0
j === p.path.length - 1 && p.hasSurrendered ? "text-[#888]" : ? "text-[#555]"
"text-[#888]" : j === p.path.length - 1 && p.hasWon
}>{t}</span> ? "text-green-400 font-semibold"
: j === p.path.length - 1 && p.hasSurrendered
? "text-[#888]"
: "text-[#888]"
}
>
{t}
</span>
</span> </span>
))} ))}
<span className="text-[#555] ml-1">({p.path.length - 1} clic{p.path.length - 1 > 1 ? "s" : ""})</span> <span className="text-[#555] ml-1">
({p.path.length - 1} clic
{p.path.length - 1 > 1 ? "s" : ""})
</span>
</div> </div>
)} )}
</li> </li>
@@ -99,33 +156,51 @@ export function GameScreen({
</div> </div>
{isHost ? ( {isHost ? (
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{room.round < room.totalRounds {room.round < room.totalRounds ? (
? <button className={btnPrimary} onClick={onNextRound}>Manche suivante</button> <button className={btnPrimary} onClick={onNextRound}>
: <button className={btnPrimary} onClick={onResetGame}>Partie terminée Recommencer</button> Manche suivante
} </button>
) : (
<button className={btnPrimary} onClick={onResetGame}>
Partie terminée - Recommencer
</button>
)}
{room.round < room.totalRounds && ( {room.round < room.totalRounds && (
<button className={btnGhost} onClick={onResetGame}>Arrêter la partie</button> <button className={btnGhost} onClick={onResetGame}>
Arrêter la partie
</button>
)} )}
</div> </div>
) : ( ) : (
<p className="text-xs sm:text-sm text-[#888] text-center animate-pulse-slow">En attente de l&apos;hôte...</p> <p className="text-xs sm:text-sm text-[#888] text-center animate-pulse-slow">
En attente de l&apos;hôte...
</p>
)} )}
</div> </div>
</div> </div>
); );
// Entre les manches : attente que l'hôte démarre la suivante // Entre les manches : attente que l'hôte démarre la suivante
if (room.phase === "waiting") return ( if (room.phase === "waiting")
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] flex items-center justify-center px-4"> <div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] flex items-center justify-center px-4">
<div className="text-center flex flex-col items-center gap-4"> <div className="text-center flex flex-col items-center gap-4">
<div className="text-2xl font-black">Manche {room.round}/{room.totalRounds} terminée</div> <div className="text-2xl font-black">
Manche {room.round}/{room.totalRounds} terminée
</div>
<p className="text-sm text-[#888] animate-pulse-slow"> <p className="text-sm text-[#888] animate-pulse-slow">
{isHost ? "Lance la manche suivante quand tu veux." : "En attente que l'hôte démarre la prochaine manche..."} {isHost
? "Lance la manche suivante quand tu veux."
: "En attente que l'hôte démarre la prochaine manche..."}
</p> </p>
{isHost && ( {isHost && (
<div className="flex flex-col gap-2.5 w-full max-w-xs mt-2"> <div className="flex flex-col gap-2.5 w-full max-w-xs mt-2">
<button className={btnPrimary} onClick={onNextRound}>Manche suivante</button> <button className={btnPrimary} onClick={onNextRound}>
<button className={btnGhost} onClick={onResetGame}>Arrêter la partie</button> Manche suivante
</button>
<button className={btnGhost} onClick={onResetGame}>
Arrêter la partie
</button>
</div> </div>
)} )}
</div> </div>
@@ -133,18 +208,29 @@ export function GameScreen({
); );
// Countdown // Countdown
if (room.phase === "countdown") return ( if (room.phase === "countdown")
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] flex items-center justify-center px-4"> <div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] flex items-center justify-center px-4">
<div className="text-center"> <div className="text-center">
<div className="flex items-center justify-center gap-6 sm:gap-10 flex-col sm:flex-row mb-10 sm:mb-12"> <div className="flex items-center justify-center gap-6 sm:gap-10 flex-col sm:flex-row mb-10 sm:mb-12">
<div className="flex flex-col items-center gap-1 max-w-48"> <div className="flex flex-col items-center gap-1 max-w-48">
<span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">Départ</span> <span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">
<span className="text-base sm:text-lg font-bold text-center">{room.startArticle}</span> Départ
</span>
<span className="text-base sm:text-lg font-bold text-center">
{room.startArticle}
</span>
</div> </div>
<span className="text-xl sm:text-2xl text-[#888] rotate-90 sm:rotate-0"></span> <span className="text-xl sm:text-2xl text-[#888] rotate-90 sm:rotate-0">
</span>
<div className="flex flex-col items-center gap-1 max-w-48"> <div className="flex flex-col items-center gap-1 max-w-48">
<span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">Cible</span> <span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">
<span className="text-lg sm:text-xl font-bold text-[#7c3aed] text-center">{room.targetArticle}</span> Cible
</span>
<span className="text-lg sm:text-xl font-bold text-[#7c3aed] text-center">
{room.targetArticle}
</span>
</div> </div>
</div> </div>
<div className="text-[clamp(72px,20vw,140px)] font-black leading-none text-[#7c3aed] animate-count-pop"> <div className="text-[clamp(72px,20vw,140px)] font-black leading-none text-[#7c3aed] animate-count-pop">
@@ -161,8 +247,12 @@ export function GameScreen({
<div className="sticky top-0 z-50 bg-[#0f0f0f]/97 backdrop-blur-sm border-b border-[#2e2e2e] flex items-center gap-2 sm:gap-3 px-3 sm:px-3.5 py-2"> <div className="sticky top-0 z-50 bg-[#0f0f0f]/97 backdrop-blur-sm border-b border-[#2e2e2e] flex items-center gap-2 sm:gap-3 px-3 sm:px-3.5 py-2">
<div className="flex-1 min-w-0 flex flex-col gap-0.5 sm:gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-0.5 sm:gap-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[9px] sm:text-[10px] font-bold uppercase tracking-wider text-[#888] shrink-0">Cible</span> <span className="text-[9px] sm:text-[10px] font-bold uppercase tracking-wider text-[#888] shrink-0">
<span className="text-xs font-bold text-[#7c3aed] truncate">{room.targetArticle}</span> Cible
</span>
<span className="text-xs font-bold text-[#7c3aed] truncate">
{room.targetArticle}
</span>
</div> </div>
<Breadcrumbs history={history} endRef={breadcrumbEndRef} /> <Breadcrumbs history={history} endRef={breadcrumbEndRef} />
</div> </div>
@@ -181,10 +271,14 @@ export function GameScreen({
</button> </button>
)} )}
{myFinished && myPlayer?.hasWon && ( {myFinished && myPlayer?.hasWon && (
<span className="text-xs font-bold text-green-400 shrink-0"> Trouvé !</span> <span className="text-xs font-bold text-green-400 shrink-0">
Trouvé !
</span>
)} )}
{myFinished && myPlayer?.hasSurrendered && ( {myFinished && myPlayer?.hasSurrendered && (
<span className="text-xs font-bold text-[#888] shrink-0">Forfait</span> <span className="text-xs font-bold text-[#888] shrink-0">
Forfait
</span>
)} )}
</div> </div>
</div> </div>
@@ -200,7 +294,10 @@ export function GameScreen({
{loadError && ( {loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center"> <div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{loadError}</p> <p className="text-[#888] text-sm">{loadError}</p>
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white hover:bg-[#1d4ed8] cursor-pointer" onClick={onRetry}> <button
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white hover:bg-[#1d4ed8] cursor-pointer"
onClick={onRetry}
>
Réessayer Réessayer
</button> </button>
</div> </div>
@@ -208,21 +305,39 @@ export function GameScreen({
{!loading && !loadError && html && ( {!loading && !loadError && html && (
<div className="article-container"> <div className="article-container">
<h1 className="article-title">{title}</h1> <h1 className="article-title">{title}</h1>
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} /> <ArticleView
html={html}
onNavigate={onNavigate}
disabled={loading}
/>
</div> </div>
)} )}
</div> </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"> <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> <h4 className="text-[10px] font-bold uppercase tracking-wider text-[#888]">
Joueurs
</h4>
<ul className="flex flex-col gap-2.5"> <ul className="flex flex-col gap-2.5">
{sortedPlayers.map((p) => ( {sortedPlayers.map((p) => (
<li key={p.id} className="flex flex-col gap-0.5"> <li key={p.id} className="flex flex-col gap-0.5">
<span className={`text-xs font-semibold truncate ${p.id === playerId ? "text-[#7c3aed]" : "text-[#f0f0f0]"}`}>{p.name}</span> <span
className={`text-xs font-semibold truncate ${p.id === playerId ? "text-[#7c3aed]" : "text-[#f0f0f0]"}`}
>
{p.name}
</span>
<span className="text-xs text-[#888]">{p.score} pts</span> <span className="text-xs text-[#888]">{p.score} pts</span>
{p.hasWon && <span className="text-[10px] text-green-400 font-bold"> Trouvé</span>} {p.hasWon && (
{p.hasSurrendered && <span className="text-[10px] text-[#888] font-bold">Forfait</span>} <span className="text-[10px] text-green-400 font-bold">
Trouvé
</span>
)}
{p.hasSurrendered && (
<span className="text-[10px] text-[#888] font-bold">
Forfait
</span>
)}
</li> </li>
))} ))}
</ul> </ul>
+67 -15
View File
@@ -35,18 +35,34 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) {
useEffect(() => { useEffect(() => {
fetch("/api/leaderboard") fetch("/api/leaderboard")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { setRows(data); setLoading(false); }); .then((data) => {
setRows(data);
setLoading(false);
});
}, []); }, []);
if (viewingUserId) { if (viewingUserId) {
return <PublicProfileScreen userId={viewingUserId} onBack={() => setViewingUserId(null)} />; return (
<PublicProfileScreen
userId={viewingUserId}
onBack={() => setViewingUserId(null)}
/>
);
} }
const sorted = [...rows] const sorted = [...rows]
.filter((r) => mode === "solo" ? r.soloGames > 0 : mode === "multi" ? r.multiGames > 0 : true) .filter((r) =>
mode === "solo"
? r.soloGames > 0
: mode === "multi"
? r.multiGames > 0
: true,
)
.sort((a, b) => { .sort((a, b) => {
if (mode === "solo") return b.soloWins - a.soloWins || b.soloGames - a.soloGames; if (mode === "solo")
if (mode === "multi") return b.multiWins - a.multiWins || b.multiGames - a.multiGames; return b.soloWins - a.soloWins || b.soloGames - a.soloGames;
if (mode === "multi")
return b.multiWins - a.multiWins || b.multiGames - a.multiGames;
return b.wins - a.wins || b.totalGames - a.totalGames; return b.wins - a.wins || b.totalGames - a.totalGames;
}); });
@@ -77,28 +93,64 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) {
</div> </div>
{loading ? ( {loading ? (
<div className="flex justify-center py-12"><div className="spinner" /></div> <div className="flex justify-center py-12">
<div className="spinner" />
</div>
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<p className="text-[#888] text-sm text-center py-12">Aucune partie jouée pour le moment.</p> <p className="text-[#888] text-sm text-center py-12">
Aucune partie jouée pour le moment.
</p>
) : ( ) : (
<div className="flex flex-col gap-2 sm:gap-2.5"> <div className="flex flex-col gap-2 sm:gap-2.5">
{sorted.map((row, i) => { {sorted.map((row, i) => {
const games = mode === "solo" ? row.soloGames : mode === "multi" ? row.multiGames : row.totalGames; const games =
const wins = mode === "solo" ? row.soloWins : mode === "multi" ? row.multiWins : row.wins; mode === "solo"
? row.soloGames
: mode === "multi"
? row.multiGames
: row.totalGames;
const wins =
mode === "solo"
? row.soloWins
: mode === "multi"
? row.multiWins
: row.wins;
return ( return (
<div key={row.id} onClick={() => setViewingUserId(row.id)} className={`flex items-center gap-2.5 px-3.5 py-3 rounded-xl border cursor-pointer hover:brightness-110 transition-all ${i < 3 ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}> <div
<span className="text-xl w-8 text-center shrink-0">{MEDALS[i] ?? `#${i + 1}`}</span> key={row.id}
onClick={() => setViewingUserId(row.id)}
className={`flex items-center gap-2.5 px-3.5 py-3 rounded-xl border cursor-pointer hover:brightness-110 transition-all ${i < 3 ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}
>
<span className="text-xl w-8 text-center shrink-0">
{MEDALS[i] ?? `#${i + 1}`}
</span>
<div className="flex-1 min-w-0 flex flex-col gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-1">
<span className="font-bold text-sm truncate">{row.name}</span> <span className="font-bold text-sm truncate">{row.name}</span>
<div className="flex flex-wrap gap-x-3 gap-y-0.5"> <div className="flex flex-wrap gap-x-3 gap-y-0.5">
<span className="text-xs text-[#888]"><span className="font-bold text-[#f0f0f0]">{wins}</span> victoires</span> <span className="text-xs text-[#888]">
<span className="text-xs text-[#888]"><span className="font-bold text-[#f0f0f0]">{games}</span> parties</span> <span className="font-bold text-[#f0f0f0]">{wins}</span>{" "}
victoires
</span>
<span className="text-xs text-[#888]">
<span className="font-bold text-[#f0f0f0]">{games}</span>{" "}
parties
</span>
{row.avgClicks != null && ( {row.avgClicks != null && (
<span className="text-xs text-[#888]"><span className="font-bold text-[#f0f0f0]">{row.avgClicks}</span> clics moy.</span> <span className="text-xs text-[#888]">
<span className="font-bold text-[#f0f0f0]">
{row.avgClicks}
</span>{" "}
clics moy.
</span>
)} )}
{row.bestTime != null && ( {row.bestTime != null && (
<span className="text-xs text-[#888]"><span className="font-bold text-[#f0f0f0]">{fmt(row.bestTime)}</span> meilleur</span> <span className="text-xs text-[#888]">
<span className="font-bold text-[#f0f0f0]">
{fmt(row.bestTime)}
</span>{" "}
meilleur
</span>
)} )}
</div> </div>
</div> </div>
+110 -33
View File
@@ -35,14 +35,24 @@ function computeStats(games: Game[]): Stats {
return { return {
total: games.length, total: games.length,
won: won.length, won: won.length,
avgClicks: won.length ? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length) : 0, avgClicks: won.length
avgTime: won.length ? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length : 0, ? 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, bestClicks: won.length ? Math.min(...won.map((g) => g.clicks)) : 0,
bestTime: won.length ? Math.min(...won.map((g) => g.timeSeconds)) : 0, bestTime: won.length ? Math.min(...won.map((g) => g.timeSeconds)) : 0,
}; };
} }
export function ProfileScreen({ userName, onBack }: { userName: string; onBack: () => void }) { export function ProfileScreen({
userName,
onBack,
}: {
userName: string;
onBack: () => void;
}) {
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "solo" | "multi">("all"); const [filter, setFilter] = useState<"all" | "solo" | "multi">("all");
@@ -63,22 +73,29 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const filtered = filter === "all" ? games : games.filter((g) => g.mode === filter); const filtered =
filter === "all" ? games : games.filter((g) => g.mode === filter);
const stats = computeStats(filtered); const stats = computeStats(filtered);
const statCard = "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1"; const statCard =
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 transition-colors"; "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1";
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 transition-colors";
return ( return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-2xl mx-auto px-4 pb-10"> <div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-2xl mx-auto px-4 pb-10">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between py-4 gap-2"> <div className="flex items-center justify-between py-4 gap-2">
<button className={btnGhost} onClick={onBack}> Retour</button> <button className={btnGhost} onClick={onBack}>
Retour
</button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-base sm:text-lg font-bold shrink-0"> <span className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-base sm:text-lg font-bold shrink-0">
{userName[0].toUpperCase()} {userName[0].toUpperCase()}
</span> </span>
<h2 className="text-base sm:text-xl font-bold truncate max-w-32 sm:max-w-none">{userName}</h2> <h2 className="text-base sm:text-xl font-bold truncate max-w-32 sm:max-w-none">
{userName}
</h2>
</div> </div>
<button <button
className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-red-950/40 border border-red-800 text-red-400 hover:bg-red-900/40 hover:text-red-300 cursor-pointer transition-colors" className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-red-950/40 border border-red-800 text-red-400 hover:bg-red-900/40 hover:text-red-300 cursor-pointer transition-colors"
@@ -91,28 +108,50 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4"> <div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4">
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{stats.total}</span> <span className="text-lg sm:text-[22px] font-black">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Parties</span> {stats.total}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Parties
</span>
</div> </div>
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{stats.won}</span> <span className="text-lg sm:text-[22px] font-black">{stats.won}</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Victoires</span> <span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Victoires
</span>
</div> </div>
<div className={statCard}> <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">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Clics moy.</span> {stats.avgClicks > 0 ? stats.avgClicks : "-"}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Clics moy.
</span>
</div> </div>
<div className={statCard}> <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">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Temps moy.</span> {stats.avgTime > 0 ? fmt(stats.avgTime) : "-"}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Temps moy.
</span>
</div> </div>
<div className={`${statCard} border-[#7c3aed]`}> <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]">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur clics</span> {stats.bestClicks > 0 ? stats.bestClicks : "-"}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Meilleur clics
</span>
</div> </div>
<div className={`${statCard} border-[#7c3aed]`}> <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]">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur temps</span> {stats.bestTime > 0 ? fmt(stats.bestTime) : "-"}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Meilleur temps
</span>
</div> </div>
</div> </div>
@@ -132,22 +171,39 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
{/* Game list */} {/* Game list */}
<div className="flex flex-col gap-2 sm:gap-2.5"> <div className="flex flex-col gap-2 sm:gap-2.5">
{loading && ( {loading && (
<div className="flex items-center gap-3 py-10 text-[#888]"><div className="spinner" /> Chargement...</div> <div className="flex items-center gap-3 py-10 text-[#888]">
<div className="spinner" /> Chargement...
</div>
)} )}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<p className="text-[#888] text-sm text-center py-10">Aucune partie enregistrée.</p> <p className="text-[#888] text-sm text-center py-10">
Aucune partie enregistrée.
</p>
)} )}
{filtered.map((g) => ( {filtered.map((g) => (
<div key={g.id} className={`bg-[#1a1a1a] rounded-xl px-3.5 sm:px-4 py-3 sm:py-3.5 flex flex-col gap-2 border-l-4 border-y border-r ${g.won ? "border-l-[#16a34a] border-[#2e2e2e]" : "border-l-[#2e2e2e] border-[#2e2e2e] opacity-70"}`}> <div
key={g.id}
className={`bg-[#1a1a1a] rounded-xl px-3.5 sm:px-4 py-3 sm:py-3.5 flex flex-col gap-2 border-l-4 border-y border-r ${g.won ? "border-l-[#16a34a] border-[#2e2e2e]" : "border-l-[#2e2e2e] border-[#2e2e2e] opacity-70"}`}
>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="bg-[#242424] rounded px-1.5 sm:px-2 py-0.5 font-semibold text-[#888] shrink-0">{g.mode === "solo" ? "Solo" : "Multi"}</span> <span className="bg-[#242424] rounded px-1.5 sm:px-2 py-0.5 font-semibold text-[#888] shrink-0">
<span className="text-[#888] ml-auto shrink-0">{new Date(g.playedAt).toLocaleDateString("fr-FR")}</span> {g.mode === "solo" ? "Solo" : "Multi"}
<span className={`font-bold shrink-0 ${g.won ? "text-[#16a34a]" : "text-[#888]"}`}>{g.won ? "Victoire" : "Abandon"}</span> </span>
<span className="text-[#888] ml-auto shrink-0">
{new Date(g.playedAt).toLocaleDateString("fr-FR")}
</span>
<span
className={`font-bold shrink-0 ${g.won ? "text-[#16a34a]" : "text-[#888]"}`}
>
{g.won ? "Victoire" : "Abandon"}
</span>
</div> </div>
<div className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm font-semibold flex-wrap"> <div className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm font-semibold flex-wrap">
<span className="truncate max-w-[40%]">{g.startArticle}</span> <span className="truncate max-w-[40%]">{g.startArticle}</span>
<span className="text-[#888] shrink-0"></span> <span className="text-[#888] shrink-0"></span>
<span className="text-[#7c3aed] truncate max-w-[40%]">{g.targetArticle}</span> <span className="text-[#7c3aed] truncate max-w-[40%]">
{g.targetArticle}
</span>
</div> </div>
{g.won && ( {g.won && (
<div className="flex flex-wrap gap-2 sm:gap-4 text-xs text-[#888]"> <div className="flex flex-wrap gap-2 sm:gap-4 text-xs text-[#888]">
@@ -161,7 +217,15 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
{g.path.map((t, i) => ( {g.path.map((t, i) => (
<span key={i} className="flex items-center gap-0.5"> <span key={i} className="flex items-center gap-0.5">
{i > 0 && <span className="text-[#555]"></span>} {i > 0 && <span className="text-[#555]"></span>}
<span className={i === g.path.length - 1 && g.won ? "text-[#16a34a] font-semibold" : ""}>{t}</span> <span
className={
i === g.path.length - 1 && g.won
? "text-[#16a34a] font-semibold"
: ""
}
>
{t}
</span>
</span> </span>
))} ))}
</div> </div>
@@ -173,15 +237,23 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
{/* Mes données */} {/* Mes données */}
<div className="mt-8 border border-[#2e2e2e] rounded-xl p-4 flex items-center justify-between gap-3 flex-wrap"> <div className="mt-8 border border-[#2e2e2e] rounded-xl p-4 flex items-center justify-between gap-3 flex-wrap">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<p className="text-xs font-bold text-[#f0f0f0] uppercase tracking-wider">Mes données</p> <p className="text-xs font-bold text-[#f0f0f0] uppercase tracking-wider">
<p className="text-xs text-[#888]">Télécharge toutes tes données personnelles (RGPD).</p> Mes données
</p>
<p className="text-xs text-[#888]">
Télécharge toutes tes données personnelles (RGPD).
</p>
</div> </div>
<a <a
href="/api/account" href="/api/account"
download download
className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] transition-colors shrink-0 inline-flex items-center gap-1.5" className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] transition-colors shrink-0 inline-flex items-center gap-1.5"
> >
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-none stroke-current stroke-2" aria-hidden> <svg
viewBox="0 0 24 24"
className="w-3.5 h-3.5 fill-none stroke-current stroke-2"
aria-hidden
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" /> <polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" /> <line x1="12" y1="15" x2="12" y2="3" />
@@ -192,10 +264,14 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
{/* Danger zone */} {/* Danger zone */}
<div className="mt-8 border border-red-900/50 rounded-xl p-4 flex flex-col gap-3"> <div className="mt-8 border border-red-900/50 rounded-xl p-4 flex flex-col gap-3">
<h3 className="text-xs font-bold text-red-400 uppercase tracking-wider">Zone dangereuse</h3> <h3 className="text-xs font-bold text-red-400 uppercase tracking-wider">
Zone dangereuse
</h3>
{!confirmDelete ? ( {!confirmDelete ? (
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xs sm:text-sm text-[#888]">Supprime définitivement ton compte et toutes tes parties.</p> <p className="text-xs sm:text-sm text-[#888]">
Supprime définitivement ton compte et toutes tes parties.
</p>
<button <button
className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-transparent border border-red-800 text-red-400 hover:bg-red-950/40 cursor-pointer transition-colors shrink-0" className="min-h-9 px-3 rounded-lg text-xs sm:text-sm font-semibold bg-transparent border border-red-800 text-red-400 hover:bg-red-950/40 cursor-pointer transition-colors shrink-0"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
@@ -206,7 +282,8 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-xs sm:text-sm text-red-300 font-semibold"> <p className="text-xs sm:text-sm text-red-300 font-semibold">
Cette action est irréversible. Toutes tes données seront supprimées. Cette action est irréversible. Toutes tes données seront
supprimées.
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
+76 -22
View File
@@ -48,20 +48,29 @@ export function PublicProfileScreen({
useEffect(() => { useEffect(() => {
fetch(`/api/users/${userId}`) fetch(`/api/users/${userId}`)
.then((r) => { .then((r) => {
if (r.status === 404) { setNotFound(true); return null; } if (r.status === 404) {
setNotFound(true);
return null;
}
return r.json(); return r.json();
}) })
.then((d) => { if (d) setProfile(d); }) .then((d) => {
if (d) setProfile(d);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [userId]); }, [userId]);
const statCard = "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1"; const statCard =
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 transition-colors"; "bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-3 sm:p-3.5 text-center flex flex-col gap-1";
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 transition-colors";
return ( return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-2xl mx-auto px-4 pb-10"> <div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-2xl mx-auto px-4 pb-10">
<div className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3 py-4">
<button className={btnGhost} onClick={onBack}> Retour</button> <button className={btnGhost} onClick={onBack}>
Retour
</button>
{profile && ( {profile && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-base sm:text-lg font-bold shrink-0"> <span className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-[#7c3aed] text-white flex items-center justify-center text-base sm:text-lg font-bold shrink-0">
@@ -70,64 +79,109 @@ export function PublicProfileScreen({
<div> <div>
<h2 className="text-base sm:text-xl font-bold">{profile.name}</h2> <h2 className="text-base sm:text-xl font-bold">{profile.name}</h2>
<p className="text-[10px] text-[#555]"> <p className="text-[10px] text-[#555]">
Membre depuis {new Date(profile.createdAt).toLocaleDateString("fr-FR", { month: "long", year: "numeric" })} Membre depuis{" "}
{new Date(profile.createdAt).toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
})}
</p> </p>
</div> </div>
</div> </div>
)} )}
</div> </div>
{loading && <div className="text-[#888] text-sm py-10 text-center animate-pulse">Chargement...</div>} {loading && (
{notFound && <p className="text-[#888] text-sm py-10 text-center">Joueur introuvable.</p>} <div className="text-[#888] text-sm py-10 text-center animate-pulse">
Chargement...
</div>
)}
{notFound && (
<p className="text-[#888] text-sm py-10 text-center">
Joueur introuvable.
</p>
)}
{profile && ( {profile && (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{/* Stats principales */} {/* Stats principales */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5"> <div className="grid grid-cols-3 gap-2 sm:gap-2.5">
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{profile.stats.total}</span> <span className="text-lg sm:text-[22px] font-black">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Parties</span> {profile.stats.total}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Parties
</span>
</div> </div>
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{profile.stats.wins}</span> <span className="text-lg sm:text-[22px] font-black">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Victoires</span> {profile.stats.wins}
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Victoires
</span>
</div> </div>
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black">{profile.stats.winRate}%</span> <span className="text-lg sm:text-[22px] font-black">
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Win rate</span> {profile.stats.winRate}%
</span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Win rate
</span>
</div> </div>
<div className={statCard}> <div className={statCard}>
<span className="text-lg sm:text-[22px] font-black"> <span className="text-lg sm:text-[22px] font-black">
{profile.stats.avgClicks ?? "-"} {profile.stats.avgClicks ?? "-"}
</span> </span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Clics moy.</span> <span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Clics moy.
</span>
</div> </div>
<div className={`${statCard} border-[#7c3aed]`}> <div className={`${statCard} border-[#7c3aed]`}>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]"> <span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
{profile.stats.bestClicks ?? "-"} {profile.stats.bestClicks ?? "-"}
</span> </span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur clics</span> <span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Meilleur clics
</span>
</div> </div>
<div className={`${statCard} border-[#7c3aed]`}> <div className={`${statCard} border-[#7c3aed]`}>
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]"> <span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
{profile.stats.bestTime ? fmt(profile.stats.bestTime) : "-"} {profile.stats.bestTime ? fmt(profile.stats.bestTime) : "-"}
</span> </span>
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur temps</span> <span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
Meilleur temps
</span>
</div> </div>
</div> </div>
{/* Stats par mode */} {/* Stats par mode */}
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-2"> <div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-2">
<h3 className="text-[10px] font-bold text-[#555] uppercase tracking-widest mb-1">Par mode</h3> <h3 className="text-[10px] font-bold text-[#555] uppercase tracking-widest mb-1">
Par mode
</h3>
{(["solo", "multi", "daily", "blitz"] as const).map((m) => { {(["solo", "multi", "daily", "blitz"] as const).map((m) => {
const s = profile.stats[m]; const s = profile.stats[m];
if (s.games === 0) return null; if (s.games === 0) return null;
return ( return (
<div key={m} className="flex items-center justify-between text-sm py-1.5 border-b border-[#2e2e2e] last:border-0"> <div
<span className="font-semibold text-[#f0f0f0]">{MODE_LABELS[m]}</span> key={m}
className="flex items-center justify-between text-sm py-1.5 border-b border-[#2e2e2e] last:border-0"
>
<span className="font-semibold text-[#f0f0f0]">
{MODE_LABELS[m]}
</span>
<div className="flex items-center gap-3 text-[#888] text-xs"> <div className="flex items-center gap-3 text-[#888] text-xs">
<span><span className="text-[#f0f0f0] font-bold">{s.wins}</span> victoires</span> <span>
<span><span className="text-[#f0f0f0] font-bold">{s.games}</span> parties</span> <span className="text-[#f0f0f0] font-bold">{s.wins}</span>{" "}
victoires
</span>
<span>
<span className="text-[#f0f0f0] font-bold">
{s.games}
</span>{" "}
parties
</span>
</div> </div>
</div> </div>
); );
+90 -39
View File
@@ -42,35 +42,61 @@ type Props = {
}; };
export function ScreenRouter({ export function ScreenRouter({
screen, setScreen, session, solo, multi, handlers, screen,
playerName, setPlayerName, joinCode, setJoinCode, setScreen,
maxPlayers, setMaxPlayers, session,
totalRounds, setTotalRounds, solo,
gameMode, setGameMode, multi,
error, setError, loading, showAuth, setShowAuth, handlers,
playerName,
setPlayerName,
joinCode,
setJoinCode,
maxPlayers,
setMaxPlayers,
totalRounds,
setTotalRounds,
gameMode,
setGameMode,
error,
setError,
loading,
showAuth,
setShowAuth,
}: Props) { }: Props) {
if (screen === "profile") return ( if (screen === "profile")
<ProfileScreen userName={session?.user?.name ?? "Joueur"} onBack={() => setScreen("home")} /> return (
<ProfileScreen
userName={session?.user?.name ?? "Joueur"}
onBack={() => setScreen("home")}
/>
); );
if (screen === "leaderboard") return ( if (screen === "leaderboard")
<LeaderboardScreen onBack={() => setScreen("home")} /> return <LeaderboardScreen onBack={() => setScreen("home")} />;
if (screen === "daily")
return (
<DailyScreen
onBack={() => setScreen("home")}
currentUserId={session?.user?.id ?? undefined}
/>
); );
if (screen === "daily") return ( if (screen === "blitz")
<DailyScreen onBack={() => setScreen("home")} currentUserId={session?.user?.id ?? undefined} /> return <BlitzScreen onBack={() => setScreen("home")} />;
);
if (screen === "blitz") return ( if (screen === "home")
<BlitzScreen onBack={() => setScreen("home")} /> return (
);
if (screen === "home") return (
<> <>
<HomeScreen <HomeScreen
playerName={playerName} setPlayerName={setPlayerName} playerName={playerName}
joinCode={joinCode} setJoinCode={setJoinCode} setPlayerName={setPlayerName}
error={error} setError={setError} loading={loading} joinCode={joinCode}
setJoinCode={setJoinCode}
error={error}
setError={setError}
loading={loading}
onCreateRoom={handlers.handleCreateRoom} onCreateRoom={handlers.handleCreateRoom}
onJoinRoom={handlers.handleJoinRoom} onJoinRoom={handlers.handleJoinRoom}
onSolo={handlers.handleSolo} onSolo={handlers.handleSolo}
@@ -81,47 +107,72 @@ export function ScreenRouter({
onDaily={() => setScreen("daily")} onDaily={() => setScreen("daily")}
onBlitz={() => setScreen("blitz")} onBlitz={() => setScreen("blitz")}
/> />
{showAuth && <AuthModal onClose={() => setShowAuth(false)} onSuccess={() => setShowAuth(false)} />} {showAuth && (
<AuthModal
onClose={() => setShowAuth(false)}
onSuccess={() => setShowAuth(false)}
/>
)}
</> </>
); );
if (screen === "solo") return ( if (screen === "solo")
return (
<SoloScreen <SoloScreen
phase={solo.phase} puzzle={solo.puzzle} phase={solo.phase}
html={solo.html} title={solo.title} puzzle={solo.puzzle}
loading={solo.loading} loadError={solo.loadError} html={solo.html}
history={solo.history} clicks={solo.clicks} title={solo.title}
loading={solo.loading}
loadError={solo.loadError}
history={solo.history}
clicks={solo.clicks}
elapsedDisplay={fmt(solo.elapsed)} elapsedDisplay={fmt(solo.elapsed)}
canGoBack={solo.canGoBack} canGoBack={solo.canGoBack}
onStart={solo.start} onStart={solo.start}
onNavigate={solo.navigate} onNavigate={solo.navigate}
onBack={solo.goBack} onBack={solo.goBack}
onQuit={() => { solo.reset(); setScreen("home"); }} onQuit={() => {
solo.reset();
setScreen("home");
}}
onNewGame={solo.start} onNewGame={solo.start}
onRetry={solo.retryLoad} onRetry={solo.retryLoad}
/> />
); );
if (screen === "lobby" && multi.room && multi.playerId) return ( if (screen === "lobby" && multi.room && multi.playerId)
return (
<LobbyScreen <LobbyScreen
room={multi.room} playerId={multi.playerId} room={multi.room}
error={error} setError={setError} loading={loading} playerId={multi.playerId}
error={error}
setError={setError}
loading={loading}
onLeave={handlers.handleLeave} onLeave={handlers.handleLeave}
onStart={handlers.handleStartGame} onStart={handlers.handleStartGame}
onReset={handlers.handleResetGame} onReset={handlers.handleResetGame}
maxPlayers={maxPlayers} setMaxPlayers={setMaxPlayers} maxPlayers={maxPlayers}
totalRounds={totalRounds} setTotalRounds={setTotalRounds} setMaxPlayers={setMaxPlayers}
gameMode={gameMode} setGameMode={setGameMode} totalRounds={totalRounds}
setTotalRounds={setTotalRounds}
gameMode={gameMode}
setGameMode={setGameMode}
onSetSearchAllowed={(v) => multi.setSearchAllowed(v)} onSetSearchAllowed={(v) => multi.setSearchAllowed(v)}
/> />
); );
if (screen === "game" && multi.room && multi.playerId) return ( if (screen === "game" && multi.room && multi.playerId)
return (
<GameScreen <GameScreen
room={multi.room} playerId={multi.playerId} room={multi.room}
html={multi.html} title={multi.title} playerId={multi.playerId}
loading={multi.loading} loadError={multi.loadError} html={multi.html}
history={multi.history} clicks={multi.clicks} title={multi.title}
loading={multi.loading}
loadError={multi.loadError}
history={multi.history}
clicks={multi.clicks}
elapsed={fmt(multi.elapsed)} elapsed={fmt(multi.elapsed)}
countdown={multi.countdown} countdown={multi.countdown}
onNavigate={multi.navigate} onNavigate={multi.navigate}
+98 -27
View File
@@ -29,35 +29,61 @@ type SoloScreenProps = {
}; };
export function SoloScreen({ export function SoloScreen({
phase, puzzle, html, title, loading, loadError, history, clicks, phase,
elapsedDisplay, canGoBack, onStart, onNavigate, onBack, onQuit, onNewGame, onRetry, puzzle,
html,
title,
loading,
loadError,
history,
clicks,
elapsedDisplay,
canGoBack,
onStart,
onNavigate,
onBack,
onQuit,
onNewGame,
onRetry,
}: SoloScreenProps) { }: SoloScreenProps) {
const breadcrumbEndRef = useRef<HTMLDivElement>(null); const breadcrumbEndRef = useRef<HTMLDivElement>(null);
const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed(); const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed();
useEffect(() => { useEffect(() => {
breadcrumbEndRef.current?.scrollIntoView({
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" }); behavior: "smooth",
block: "nearest",
inline: "end",
});
}, [history]); }, [history]);
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 btnPrimary =
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"; "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";
if (phase === "setup") return ( if (phase === "setup")
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-8 gap-5 max-w-sm mx-auto text-center"> <div className="min-h-dvh w-full 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] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer" onClick={onQuit}> <button
className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer"
onClick={onQuit}
>
Retour Retour
</button> </button>
<h2 className="text-2xl sm:text-3xl font-black">Mode Solo</h2> <h2 className="text-2xl sm:text-3xl font-black">Mode Solo</h2>
<p className="text-[#888] text-sm sm:text-base leading-relaxed"> <p className="text-[#888] text-sm sm:text-base leading-relaxed">
Deux articles aléatoires seront choisis. Atteins l&apos;article cible en cliquant uniquement sur les liens ! Deux articles aléatoires seront choisis. Atteins l&apos;article cible
en cliquant uniquement sur les liens !
</p> </p>
<button <button
onClick={toggleSearch} onClick={toggleSearch}
className={`w-full min-h-11 rounded-xl text-sm font-semibold border transition-colors cursor-pointer flex items-center justify-between px-4 ${searchAllowed ? "bg-[#7c3aed]/10 border-[#7c3aed] text-[#a78bfa]" : "bg-[#1a1a1a] border-[#2e2e2e] text-[#888]"}`} className={`w-full min-h-11 rounded-xl text-sm font-semibold border transition-colors cursor-pointer flex items-center justify-between px-4 ${searchAllowed ? "bg-[#7c3aed]/10 border-[#7c3aed] text-[#a78bfa]" : "bg-[#1a1a1a] border-[#2e2e2e] text-[#888]"}`}
> >
<span>🔍 Recherche Ctrl+F</span> <span>🔍 Recherche Ctrl+F</span>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${searchAllowed ? "bg-[#7c3aed]/30 text-[#a78bfa]" : "bg-[#242424] text-[#555]"}`}> <span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${searchAllowed ? "bg-[#7c3aed]/30 text-[#a78bfa]" : "bg-[#242424] text-[#555]"}`}
>
{searchAllowed ? "Autorisée" : "Bloquée"} {searchAllowed ? "Autorisée" : "Bloquée"}
</span> </span>
</button> </button>
@@ -67,18 +93,23 @@ export function SoloScreen({
</div> </div>
); );
if (phase === "won") return ( if (phase === "won")
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8"> <div className="min-h-dvh w-full 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-4 sm:gap-5"> <div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-4 sm:gap-5">
<div className="text-5xl sm:text-6xl leading-none">🎉</div> <div className="text-5xl sm:text-6xl leading-none">🎉</div>
<h2 className="text-2xl sm:text-3xl font-black">Article atteint !</h2> <h2 className="text-2xl sm:text-3xl font-black">Article atteint !</h2>
<div className="flex gap-6 sm:gap-8 justify-center"> <div className="flex gap-6 sm:gap-8 justify-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">{clicks}</span> <span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">
{clicks}
</span>
<span className="text-xs text-[#888]">clics</span> <span className="text-xs text-[#888]">clics</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">{elapsedDisplay}</span> <span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">
{elapsedDisplay}
</span>
<span className="text-xs text-[#888]">temps</span> <span className="text-xs text-[#888]">temps</span>
</div> </div>
</div> </div>
@@ -86,14 +117,28 @@ export function SoloScreen({
{history.map((t, i) => ( {history.map((t, i) => (
<span key={i} className="flex items-center gap-1"> <span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-[#555]"></span>} {i > 0 && <span className="text-[#555]"></span>}
<span className={i === history.length - 1 ? "text-[#16a34a] font-semibold" : ""}>{t}</span> <span
className={
i === history.length - 1
? "text-[#16a34a] font-semibold"
: ""
}
>
{t}
</span>
</span> </span>
))} ))}
</div> </div>
<ShareBar text={`🎉 J'ai atteint "${puzzle?.target}" en ${clicks} clics et ${elapsedDisplay} sur WikiRush !`} /> <ShareBar
text={`🎉 J'ai atteint "${puzzle?.target}" en ${clicks} clics et ${elapsedDisplay} sur WikiRush !`}
/>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<button className={btnPrimary} onClick={onNewGame}>Nouvelle partie</button> <button className={btnPrimary} onClick={onNewGame}>
<button className={btnGhost} onClick={onQuit}>Accueil</button> Nouvelle partie
</button>
<button className={btnGhost} onClick={onQuit}>
Accueil
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -113,13 +158,20 @@ export function SoloScreen({
{loadError && ( {loadError && (
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center"> <div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
<p className="text-[#888] text-sm">{loadError}</p> <p className="text-[#888] text-sm">{loadError}</p>
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white hover:bg-[#1d4ed8] cursor-pointer" onClick={onRetry}> <button
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white hover:bg-[#1d4ed8] cursor-pointer"
onClick={onRetry}
>
Réessayer Réessayer
</button> </button>
</div> </div>
)} )}
{!loading && !loadError && html && ( {!loading && !loadError && html && (
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} /> <ArticleView
html={html}
onNavigate={onNavigate}
disabled={loading}
/>
)} )}
</div> </div>
</div> </div>
@@ -127,25 +179,44 @@ export function SoloScreen({
{/* Bottom bar */} {/* 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 sm:gap-3"> <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 sm:gap-3">
<div className="flex-1 min-w-0 flex flex-col gap-0.5"> <div className="flex-1 min-w-0 flex flex-col gap-0.5">
<span className="text-[8px] sm:text-[9px] font-bold tracking-widest text-[#888] uppercase">Trouver</span> <span className="text-[8px] sm:text-[9px] font-bold tracking-widest text-[#888] uppercase">
<span className="text-sm sm:text-base font-black text-[#7c3aed] truncate">{puzzle?.target}</span> Trouver
</span>
<span className="text-sm sm:text-base font-black text-[#7c3aed] truncate">
{puzzle?.target}
</span>
<Breadcrumbs history={history} endRef={breadcrumbEndRef} /> <Breadcrumbs history={history} endRef={breadcrumbEndRef} />
</div> </div>
<div className="flex items-center gap-2 sm:gap-3 shrink-0"> <div className="flex items-center gap-2 sm:gap-3 shrink-0">
<div className="flex flex-col items-center gap-0.5 min-w-10"> <div className="flex flex-col items-center gap-0.5 min-w-10">
<span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">Temps</span> <span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">
<span className="text-xs sm:text-sm font-black tabular-nums">{elapsedDisplay}</span> Temps
</span>
<span className="text-xs sm:text-sm font-black tabular-nums">
{elapsedDisplay}
</span>
</div> </div>
<div className="flex flex-col items-center gap-0.5 min-w-10"> <div className="flex flex-col items-center gap-0.5 min-w-10">
<span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">Clics</span> <span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">
<span className="text-xs sm:text-sm font-black tabular-nums">{clicks}</span> Clics
</span>
<span className="text-xs sm:text-sm font-black tabular-nums">
{clicks}
</span>
</div> </div>
{canGoBack && ( {canGoBack && (
<button className="min-h-8 sm:min-h-9 px-2 sm:px-3 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] disabled:opacity-50 cursor-pointer" onClick={onBack} disabled={loading}> <button
className="min-h-8 sm:min-h-9 px-2 sm:px-3 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] disabled:opacity-50 cursor-pointer"
onClick={onBack}
disabled={loading}
>
+1 +1
</button> </button>
)} )}
<button className="min-h-8 sm:min-h-9 px-2 sm:px-3 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer" onClick={onQuit}> <button
className="min-h-8 sm:min-h-9 px-2 sm:px-3 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer"
onClick={onQuit}
>
Quitter Quitter
</button> </button>
</div> </div>
+2 -2
View File
@@ -64,8 +64,8 @@ export default function MentionsLegalesPage() {
SAPINET SAPINET
</p> </p>
<p> <p>
<span className="text-[#888]">SIREN :</span> 899 483 457 (RCS <span className="text-[#888]">SIREN :</span> 899 483 457 (RCS de
de Nanterre) Nanterre)
</p> </p>
<p> <p>
<span className="text-[#888]">Adresse :</span> 65 rue de la <span className="text-[#888]">Adresse :</span> 65 rue de la
+40 -11
View File
@@ -22,20 +22,49 @@ export default function WikiRush() {
const solo = useSoloGame(); const solo = useSoloGame();
const multi = useMultiGame(); const multi = useMultiGame();
const { session } = useGameEffects({ solo, multi, screen, setScreen, setPlayerName }); const { session } = useGameEffects({
const handlers = useGameHandlers({ solo, multi, playerName, joinCode, maxPlayers, totalRounds, gameMode, setScreen, setError, setLoading }); solo,
multi,
screen,
setScreen,
setPlayerName,
});
const handlers = useGameHandlers({
solo,
multi,
playerName,
joinCode,
maxPlayers,
totalRounds,
gameMode,
setScreen,
setError,
setLoading,
});
return ( return (
<ScreenRouter <ScreenRouter
screen={screen} setScreen={setScreen} session={session} screen={screen}
solo={solo} multi={multi} handlers={handlers} setScreen={setScreen}
playerName={playerName} setPlayerName={setPlayerName} session={session}
joinCode={joinCode} setJoinCode={setJoinCode} solo={solo}
maxPlayers={maxPlayers} setMaxPlayers={setMaxPlayers} multi={multi}
totalRounds={totalRounds} setTotalRounds={setTotalRounds} handlers={handlers}
gameMode={gameMode} setGameMode={setGameMode} playerName={playerName}
error={error} setError={setError} loading={loading} setPlayerName={setPlayerName}
showAuth={showAuth} setShowAuth={setShowAuth} joinCode={joinCode}
setJoinCode={setJoinCode}
maxPlayers={maxPlayers}
setMaxPlayers={setMaxPlayers}
totalRounds={totalRounds}
setTotalRounds={setTotalRounds}
gameMode={gameMode}
setGameMode={setGameMode}
error={error}
setError={setError}
loading={loading}
showAuth={showAuth}
setShowAuth={setShowAuth}
/> />
); );
} }
+8 -2
View File
@@ -17,7 +17,9 @@ export type SessionData = {
export function saveSession(data: SessionData) { export function saveSession(data: SessionData) {
try { try {
sessionStorage.setItem(KEY, JSON.stringify(data)); sessionStorage.setItem(KEY, JSON.stringify(data));
} catch { /* ignore quota */ } } catch {
/* ignore quota */
}
} }
export function loadSession(): SessionData | null { export function loadSession(): SessionData | null {
@@ -30,5 +32,9 @@ export function loadSession(): SessionData | null {
} }
export function clearSession() { export function clearSession() {
try { sessionStorage.removeItem(KEY); } catch { /* ignore */ } try {
sessionStorage.removeItem(KEY);
} catch {
/* ignore */
}
} }
+9 -1
View File
@@ -1,4 +1,12 @@
export type Screen = "home" | "lobby" | "game" | "solo" | "profile" | "leaderboard" | "daily" | "blitz"; export type Screen =
| "home"
| "lobby"
| "game"
| "solo"
| "profile"
| "leaderboard"
| "daily"
| "blitz";
export type WikiArticle = { export type WikiArticle = {
title: string; title: string;
+49 -15
View File
@@ -16,7 +16,10 @@ export function useBlitzGame() {
const [timeLeft, setTimeLeft] = useState(BLITZ_DURATION); const [timeLeft, setTimeLeft] = useState(BLITZ_DURATION);
const [clicks, setClicks] = useState(0); const [clicks, setClicks] = useState(0);
const [history, setHistory] = useState<string[]>([]); const [history, setHistory] = useState<string[]>([]);
const [puzzle, setPuzzle] = useState<{ start: string; target: string } | null>(null); const [puzzle, setPuzzle] = useState<{
start: string;
target: string;
} | null>(null);
const clicksRef = useRef(0); const clicksRef = useRef(0);
const pathRef = useRef<string[]>([]); const pathRef = useRef<string[]>([]);
@@ -26,7 +29,10 @@ export function useBlitzGame() {
const startTimeRef = useRef(0); const startTimeRef = useRef(0);
function stopTimer() { function stopTimer() {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
} }
function startTimer() { function startTimer() {
@@ -46,7 +52,12 @@ export function useBlitzGame() {
useEffect(() => () => stopTimer(), []); useEffect(() => () => stopTimer(), []);
async function saveGame(won: boolean, path: string[], clicks: number, timeSeconds: number) { async function saveGame(
won: boolean,
path: string[],
clicks: number,
timeSeconds: number,
) {
if (!puzzle) return; if (!puzzle) return;
fetch("/api/games", { fetch("/api/games", {
method: "POST", method: "POST",
@@ -70,7 +81,10 @@ export function useBlitzGame() {
const art = await fetchArticle(t); const art = await fetchArticle(t);
setLoading(false); setLoading(false);
loadingRef.current = false; loadingRef.current = false;
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; } if (!art) {
setLoadError(`Impossible de charger "${t}".`);
return null;
}
setHtml(art.html); setHtml(art.html);
setTitle(art.title); setTitle(art.title);
return art.title; return art.title;
@@ -79,8 +93,10 @@ export function useBlitzGame() {
async function start() { async function start() {
setLoading(true); setLoading(true);
stopTimer(); stopTimer();
clicksRef.current = 0; setClicks(0); clicksRef.current = 0;
pathRef.current = []; setHistory([]); setClicks(0);
pathRef.current = [];
setHistory([]);
endedRef.current = false; endedRef.current = false;
setTimeLeft(BLITZ_DURATION); setTimeLeft(BLITZ_DURATION);
setLoadError(null); setLoadError(null);
@@ -96,7 +112,8 @@ export function useBlitzGame() {
startTimer(); startTimer();
} }
const navigate = useCallback(async (t: string) => { const navigate = useCallback(
async (t: string) => {
if (loadingRef.current || endedRef.current) return; if (loadingRef.current || endedRef.current) return;
clicksRef.current += 1; clicksRef.current += 1;
setClicks(clicksRef.current); setClicks(clicksRef.current);
@@ -109,21 +126,29 @@ export function useBlitzGame() {
setHistory(newPath); setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) { if (
puzzle &&
normalizeTitle(canonical) === normalizeTitle(puzzle.target)
) {
endedRef.current = true; endedRef.current = true;
stopTimer(); stopTimer();
setPhase("won"); setPhase("won");
saveGame(true, newPath, clicksRef.current, timeLeft); saveGame(true, newPath, clicksRef.current, timeLeft);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [puzzle, timeLeft]); },
[puzzle, timeLeft],
);
function reset() { function reset() {
stopTimer(); stopTimer();
endedRef.current = false; endedRef.current = false;
clicksRef.current = 0; setClicks(0); clicksRef.current = 0;
pathRef.current = []; setHistory([]); setClicks(0);
setHtml(""); setTitle(""); pathRef.current = [];
setHistory([]);
setHtml("");
setTitle("");
setTimeLeft(BLITZ_DURATION); setTimeLeft(BLITZ_DURATION);
setPuzzle(null); setPuzzle(null);
setPhase("setup"); setPhase("setup");
@@ -131,10 +156,19 @@ export function useBlitzGame() {
} }
return { return {
phase, puzzle, html, title, loading, loadError, phase,
timeLeft, clicks, history, puzzle,
html,
title,
loading,
loadError,
timeLeft,
clicks,
history,
canGoBack: false, // pas de retour en blitz canGoBack: false, // pas de retour en blitz
start, navigate, reset, start,
navigate,
reset,
retryLoad: () => title && loadArticle(title), retryLoad: () => title && loadArticle(title),
}; };
} }
+63 -16
View File
@@ -4,7 +4,12 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { fetchArticle, normalizeTitle } from "./wiki"; import { fetchArticle, normalizeTitle } from "./wiki";
import { useTimer } from "./useTimer"; import { useTimer } from "./useTimer";
export type DailyPhase = "loading" | "playing" | "won" | "gave_up" | "already_played"; export type DailyPhase =
| "loading"
| "playing"
| "won"
| "gave_up"
| "already_played";
export type DailyPuzzleInfo = { export type DailyPuzzleInfo = {
id: string; id: string;
@@ -42,7 +47,12 @@ export function useDailyGame() {
useEffect(() => { useEffect(() => {
fetch("/api/daily") fetch("/api/daily")
.then((r) => r.json()) .then((r) => r.json())
.then(async (data: { puzzle: DailyPuzzleInfo; alreadyPlayed: boolean; myResult: DailyResult | null }) => { .then(
async (data: {
puzzle: DailyPuzzleInfo;
alreadyPlayed: boolean;
myResult: DailyResult | null;
}) => {
setPuzzle(data.puzzle); setPuzzle(data.puzzle);
if (data.alreadyPlayed && data.myResult) { if (data.alreadyPlayed && data.myResult) {
setMyResult(data.myResult); setMyResult(data.myResult);
@@ -51,13 +61,17 @@ export function useDailyGame() {
} }
// Charger l'article de départ // Charger l'article de départ
const art = await fetchArticle(data.puzzle.startArticle); const art = await fetchArticle(data.puzzle.startArticle);
if (!art) { setLoadError("Impossible de charger l'article de départ."); return; } if (!art) {
setLoadError("Impossible de charger l'article de départ.");
return;
}
setHtml(art.html); setHtml(art.html);
setTitle(art.title); setTitle(art.title);
pathRef.current = [art.title]; pathRef.current = [art.title];
setHistory([art.title]); setHistory([art.title]);
setPhase("playing"); setPhase("playing");
}) },
)
.catch(() => setLoadError("Impossible de charger le défi du jour.")); .catch(() => setLoadError("Impossible de charger le défi du jour."));
}, []); }, []);
@@ -68,7 +82,10 @@ export function useDailyGame() {
const art = await fetchArticle(t); const art = await fetchArticle(t);
setLoading(false); setLoading(false);
loadingRef.current = false; loadingRef.current = false;
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; } if (!art) {
setLoadError(`Impossible de charger "${t}".`);
return null;
}
setHtml(art.html); setHtml(art.html);
setTitle(art.title); setTitle(art.title);
return art.title; return art.title;
@@ -86,15 +103,23 @@ export function useDailyGame() {
await fetch("/api/daily", { await fetch("/api/daily", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ puzzleId: puzzle.id, ...result, path: result.path }), body: JSON.stringify({
puzzleId: puzzle.id,
...result,
path: result.path,
}),
}).catch(() => {}); }).catch(() => {});
} }
const navigate = useCallback(async (t: string) => { const navigate = useCallback(
async (t: string) => {
if (loadingRef.current || gameEndedRef.current) return; if (loadingRef.current || gameEndedRef.current) return;
clicksRef.current += 1; clicksRef.current += 1;
setClicks(clicksRef.current); setClicks(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
const canonical = await loadArticle(t); const canonical = await loadArticle(t);
if (!canonical) return; if (!canonical) return;
@@ -103,20 +128,33 @@ export function useDailyGame() {
setHistory(newPath); setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.targetArticle)) { if (
puzzle &&
normalizeTitle(canonical) === normalizeTitle(puzzle.targetArticle)
) {
timer.stop(); timer.stop();
gameEndedRef.current = true; gameEndedRef.current = true;
setPhase("won"); setPhase("won");
await submitResult(true); await submitResult(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [puzzle]); },
[puzzle],
);
const goBack = useCallback(async () => { const goBack = useCallback(async () => {
if (loadingRef.current || gameEndedRef.current || pathRef.current.length <= 1) return; if (
loadingRef.current ||
gameEndedRef.current ||
pathRef.current.length <= 1
)
return;
clicksRef.current += 1; clicksRef.current += 1;
setClicks(clicksRef.current); setClicks(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
const newPath = pathRef.current.slice(0, -1); const newPath = pathRef.current.slice(0, -1);
const canonical = await loadArticle(newPath[newPath.length - 1]); const canonical = await loadArticle(newPath[newPath.length - 1]);
if (!canonical) return; if (!canonical) return;
@@ -134,11 +172,20 @@ export function useDailyGame() {
} }
return { return {
phase, puzzle, myResult, phase,
html, title, loading, loadError, puzzle,
history, clicks, elapsed: timer.elapsed, myResult,
html,
title,
loading,
loadError,
history,
clicks,
elapsed: timer.elapsed,
canGoBack: pathRef.current.length > 1, canGoBack: pathRef.current.length > 1,
navigate, goBack, giveUp, navigate,
goBack,
giveUp,
retryLoad: () => title && loadArticle(title), retryLoad: () => title && loadArticle(title),
}; };
} }
+19 -5
View File
@@ -37,18 +37,32 @@ export function useGameEffects({
const saved = loadSession(); const saved = loadSession();
if (!saved) return; if (!saved) return;
if (saved.screen === "solo" && saved.soloPuzzle && saved.soloHistory?.length) { if (
saved.screen === "solo" &&
saved.soloPuzzle &&
saved.soloHistory?.length
) {
setScreen("solo"); setScreen("solo");
solo.restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0) solo
.then((ok) => { if (!ok) { clearSession(); setScreen("home"); } }); .restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0)
.then((ok) => {
if (!ok) {
clearSession();
setScreen("home");
}
});
} else if ( } else if (
(saved.screen === "lobby" || saved.screen === "game") && (saved.screen === "lobby" || saved.screen === "game") &&
saved.multiRoomCode && saved.multiPlayerId saved.multiRoomCode &&
saved.multiPlayerId
) { ) {
if (saved.playerName) setPlayerName(saved.playerName); if (saved.playerName) setPlayerName(saved.playerName);
multi.restore(saved.multiRoomCode, saved.multiPlayerId).then((ok) => { multi.restore(saved.multiRoomCode, saved.multiPlayerId).then((ok) => {
if (ok) setScreen("lobby"); if (ok) setScreen("lobby");
else { clearSession(); setScreen("home"); } else {
clearSession();
setScreen("home");
}
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+48 -13
View File
@@ -20,29 +20,64 @@ type Handlers = {
}; };
export function useGameHandlers({ export function useGameHandlers({
solo, multi, playerName, joinCode, maxPlayers, totalRounds, gameMode, setScreen, setError, setLoading, solo,
multi,
playerName,
joinCode,
maxPlayers,
totalRounds,
gameMode,
setScreen,
setError,
setLoading,
}: Handlers) { }: Handlers) {
async function handleCreateRoom() { async function handleCreateRoom() {
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; } if (!playerName.trim()) {
setLoading(true); setError(null); setError("Entre ton pseudo !");
const { error: err } = await multi.createRoom(playerName.trim(), maxPlayers, totalRounds, gameMode); return;
}
setLoading(true);
setError(null);
const { error: err } = await multi.createRoom(
playerName.trim(),
maxPlayers,
totalRounds,
gameMode,
);
setLoading(false); setLoading(false);
if (err) { setError(err); return; } if (err) {
setError(err);
return;
}
setScreen("lobby"); setScreen("lobby");
} }
async function handleJoinRoom() { async function handleJoinRoom() {
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; } if (!playerName.trim()) {
if (joinCode.trim().length !== 4) { setError("Le code doit faire 4 lettres"); return; } setError("Entre ton pseudo !");
setLoading(true); setError(null); return;
const { error: err } = await multi.joinRoom(playerName.trim(), joinCode.trim().toUpperCase()); }
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); setLoading(false);
if (err) { setError(err); return; } if (err) {
setError(err);
return;
}
setScreen("lobby"); setScreen("lobby");
} }
async function handleStartGame() { async function handleStartGame() {
setLoading(true); setError(null); setLoading(true);
setError(null);
const { error: err } = await multi.startGame(); const { error: err } = await multi.startGame();
setLoading(false); setLoading(false);
if (err) setError(err); if (err) setError(err);
@@ -50,12 +85,12 @@ export function useGameHandlers({
async function handleNextRound() { async function handleNextRound() {
await multi.nextRound(); await multi.nextRound();
// On reste sur "game" le GameScreen affiche le lobby quand phase === "waiting" // On reste sur "game" - le GameScreen affiche le lobby quand phase === "waiting"
} }
async function handleResetGame() { async function handleResetGame() {
await multi.resetGame(); await multi.resetGame();
// On reste sur "game" le GameScreen affiche le lobby quand phase === "waiting" // On reste sur "game" - le GameScreen affiche le lobby quand phase === "waiting"
} }
function handleLeave() { function handleLeave() {
+145 -45
View File
@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { fetchArticle, pickTwoArticles, prefetchArticle, POLL_INTERVAL, COUNTDOWN_DURATION } from "./wiki"; import {
fetchArticle,
pickTwoArticles,
prefetchArticle,
POLL_INTERVAL,
COUNTDOWN_DURATION,
} from "./wiki";
import { useTimer } from "./useTimer"; import { useTimer } from "./useTimer";
import { saveSession, clearSession } from "./session"; import { saveSession, clearSession } from "./session";
import type { Room } from "../app/api/rooms/route"; import type { Room } from "../app/api/rooms/route";
@@ -32,11 +38,18 @@ export function useMultiGame() {
// Article loading // Article loading
async function loadArticle(t: string): Promise<string | null> { async function loadArticle(t: string): Promise<string | null> {
setLoading(true); loadingRef.current = true; setLoadError(null); setLoading(true);
loadingRef.current = true;
setLoadError(null);
const art = await fetchArticle(t); const art = await fetchArticle(t);
setLoading(false); loadingRef.current = false; setLoading(false);
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; } loadingRef.current = false;
setHtml(art.html); setTitle(art.title); if (!art) {
setLoadError(`Impossible de charger "${t}".`);
return null;
}
setHtml(art.html);
setTitle(art.title);
return art.title; return art.title;
} }
@@ -53,14 +66,20 @@ export function useMultiGame() {
} }
function stopCountdown() { function stopCountdown() {
if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; } if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
setCountdown(null); setCountdown(null);
} }
// Polling // Polling
function stopPolling() { function stopPolling() {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
} }
async function poll(code: string, pid: string) { async function poll(code: string, pid: string) {
@@ -70,8 +89,10 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "heartbeat", playerId: pid }), body: JSON.stringify({ action: "heartbeat", playerId: pid }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
const pollCodeRef = useRef<string | null>(null); const pollCodeRef = useRef<string | null>(null);
@@ -87,7 +108,11 @@ export function useMultiGame() {
// Relance le polling quand le tab redevient visible (les setInterval sont throttlés en arrière-plan) // Relance le polling quand le tab redevient visible (les setInterval sont throttlés en arrière-plan)
useEffect(() => { useEffect(() => {
function onVisible() { function onVisible() {
if (document.visibilityState === "visible" && pollCodeRef.current && pollPidRef.current) { if (
document.visibilityState === "visible" &&
pollCodeRef.current &&
pollPidRef.current
) {
poll(pollCodeRef.current, pollPidRef.current); poll(pollCodeRef.current, pollPidRef.current);
startPolling(pollCodeRef.current, pollPidRef.current); startPolling(pollCodeRef.current, pollPidRef.current);
} }
@@ -107,8 +132,10 @@ export function useMultiGame() {
prevRoundRef.current = room.round; prevRoundRef.current = room.round;
if (room.phase === "countdown" && prevPhase !== "countdown") { if (room.phase === "countdown" && prevPhase !== "countdown") {
setHtml(""); setLoadError(null); setHtml("");
clicksRef.current = 0; setClicksDisplay(0); setLoadError(null);
clicksRef.current = 0;
setClicksDisplay(0);
timerStartedRef.current = false; timerStartedRef.current = false;
timer.reset(); timer.reset();
startCountdown(room.countdownStart ?? Date.now()); startCountdown(room.countdownStart ?? Date.now());
@@ -136,18 +163,27 @@ export function useMultiGame() {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "play", playerId }), body: JSON.stringify({ action: "play", playerId }),
}).then((r) => r.json()).then((d) => { })
.then((r) => r.json())
.then((d) => {
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room); if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
}).catch(() => {}); })
.catch(() => {});
} }
}, [countdown, room, playerId]); }, [countdown, room, playerId]);
// Navigation // Navigation
const navigate = useCallback(async (t: string) => { const navigate = useCallback(
if (!room || !playerId || loadingRef.current || room.phase !== "playing") return; async (t: string) => {
clicksRef.current += 1; setClicksDisplay(clicksRef.current); if (!room || !playerId || loadingRef.current || room.phase !== "playing")
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } return;
clicksRef.current += 1;
setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
// Heartbeat optimiste pour éviter le kick pendant le chargement de l'article // Heartbeat optimiste pour éviter le kick pendant le chargement de l'article
fetch(`/api/rooms/${room.code}`, { fetch(`/api/rooms/${room.code}`, {
@@ -168,40 +204,76 @@ export function useMultiGame() {
const res = await fetch(`/api/rooms/${room.code}`, { const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "navigate", playerId, article: canonical }), body: JSON.stringify({
action: "navigate",
playerId,
article: canonical,
}),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} catch { /* on continue localement */ } } catch {
/* on continue localement */
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, playerId]); },
[room, playerId],
);
// Room actions // Room actions
async function createRoom(playerName: string, maxPlayers = 16, totalRounds = 3, gameMode: "race" | "all_finish" = "race"): Promise<{ error?: string }> { async function createRoom(
playerName: string,
maxPlayers = 16,
totalRounds = 3,
gameMode: "race" | "all_finish" = "race",
): Promise<{ error?: string }> {
const res = await fetch("/api/rooms", { const res = await fetch("/api/rooms", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playerName, maxPlayers, totalRounds, gameMode }), body: JSON.stringify({ playerName, maxPlayers, totalRounds, gameMode }),
}); });
const data = await res.json() as { room?: Room; playerId?: string; error?: string }; const data = (await res.json()) as {
room?: Room;
playerId?: string;
error?: string;
};
if (!res.ok) return { error: data.error ?? "Erreur" }; if (!res.ok) return { error: data.error ?? "Erreur" };
setRoom(data.room!); setPlayerId(data.playerId!); setRoom(data.room!);
setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!); startPolling(data.room!.code, data.playerId!);
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName }); saveSession({
screen: "lobby",
multiRoomCode: data.room!.code,
multiPlayerId: data.playerId!,
playerName,
});
return {}; return {};
} }
async function joinRoom(playerName: string, code: string): Promise<{ error?: string }> { async function joinRoom(
playerName: string,
code: string,
): Promise<{ error?: string }> {
const res = await fetch(`/api/rooms/${code}`, { const res = await fetch(`/api/rooms/${code}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "join", playerName }), body: JSON.stringify({ action: "join", playerName }),
}); });
const data = await res.json() as { room?: Room; playerId?: string; error?: string }; const data = (await res.json()) as {
room?: Room;
playerId?: string;
error?: string;
};
if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" }; if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" };
setRoom(data.room!); setPlayerId(data.playerId!); setRoom(data.room!);
setPlayerId(data.playerId!);
startPolling(data.room!.code, data.playerId!); startPolling(data.room!.code, data.playerId!);
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName }); saveSession({
screen: "lobby",
multiRoomCode: data.room!.code,
multiPlayerId: data.playerId!,
playerName,
});
return {}; return {};
} }
@@ -211,9 +283,14 @@ export function useMultiGame() {
const res = await fetch(`/api/rooms/${room.code}`, { const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "start", playerId, startArticle: puzzle.start, targetArticle: puzzle.target }), body: JSON.stringify({
action: "start",
playerId,
startArticle: puzzle.start,
targetArticle: puzzle.target,
}),
}); });
const data = await res.json() as { room?: Room; error?: string }; const data = (await res.json()) as { room?: Room; error?: string };
if (!res.ok) return { error: data.error ?? "Erreur" }; if (!res.ok) return { error: data.error ?? "Erreur" };
prefetchArticle(puzzle.start); prefetchArticle(puzzle.start);
setRoom(data.room!); setRoom(data.room!);
@@ -227,7 +304,7 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "setSearchAllowed", playerId, value }), body: JSON.stringify({ action: "setSearchAllowed", playerId, value }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
async function surrender() { async function surrender() {
@@ -237,7 +314,7 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "surrender", playerId }), body: JSON.stringify({ action: "surrender", playerId }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
async function nextRound() { async function nextRound() {
@@ -247,7 +324,7 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "nextRound", playerId }), body: JSON.stringify({ action: "nextRound", playerId }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
async function resetGame() { async function resetGame() {
@@ -257,15 +334,21 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resetGame", playerId }), body: JSON.stringify({ action: "resetGame", playerId }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
function leave() { function leave() {
stopPolling(); stopCountdown(); timer.stop(); stopPolling();
setRoom(null); setPlayerId(null); stopCountdown();
setHtml(""); setTitle(""); timer.stop();
setHistory([]); historyRef.current = []; setRoom(null);
clicksRef.current = 0; setClicksDisplay(0); setPlayerId(null);
setHtml("");
setTitle("");
setHistory([]);
historyRef.current = [];
clicksRef.current = 0;
setClicksDisplay(0);
timerStartedRef.current = false; timerStartedRef.current = false;
clearSession(); clearSession();
} }
@@ -279,7 +362,7 @@ export function useMultiGame() {
body: JSON.stringify({ action: "heartbeat", playerId: pid }), body: JSON.stringify({ action: "heartbeat", playerId: pid }),
}); });
if (!res.ok) return false; if (!res.ok) return false;
const data = await res.json() as { room: Room }; const data = (await res.json()) as { room: Room };
setRoom(data.room); setRoom(data.room);
setPlayerId(pid); setPlayerId(pid);
startPolling(code, pid); startPolling(code, pid);
@@ -290,9 +373,26 @@ export function useMultiGame() {
} }
return { return {
room, playerId, html, title, loading, loadError, room,
history, clicks: clicksDisplay, elapsed: timer.elapsed, countdown, playerId,
createRoom, joinRoom, startGame, nextRound, resetGame, leave, navigate, surrender, setSearchAllowed, restore, html,
title,
loading,
loadError,
history,
clicks: clicksDisplay,
elapsed: timer.elapsed,
countdown,
createRoom,
joinRoom,
startGame,
nextRound,
resetGame,
leave,
navigate,
surrender,
setSearchAllowed,
restore,
retryLoad: () => title && loadArticle(title), retryLoad: () => title && loadArticle(title),
}; };
} }
+2 -1
View File
@@ -36,6 +36,7 @@ export function useCtrlFBlock(allowed: boolean) {
} }
} }
window.addEventListener("keydown", handleKeyDown, { capture: true }); window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); return () =>
window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [allowed]); }, [allowed]);
} }
+71 -23
View File
@@ -33,7 +33,10 @@ export function useSoloGame() {
const art = await fetchArticle(t); const art = await fetchArticle(t);
setLoading(false); setLoading(false);
loadingRef.current = false; loadingRef.current = false;
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; } if (!art) {
setLoadError(`Impossible de charger "${t}".`);
return null;
}
setHtml(art.html); setHtml(art.html);
setTitle(art.title); setTitle(art.title);
return art.title; return art.title;
@@ -41,8 +44,10 @@ export function useSoloGame() {
async function start() { async function start() {
setLoading(true); setLoading(true);
clicksRef.current = 0; setClicksDisplay(0); clicksRef.current = 0;
pathRef.current = []; setHistory([]); setClicksDisplay(0);
pathRef.current = [];
setHistory([]);
timerStartedRef.current = false; timerStartedRef.current = false;
gameEndedRef.current = false; gameEndedRef.current = false;
timer.reset(); timer.reset();
@@ -55,14 +60,23 @@ export function useSoloGame() {
pathRef.current = [canonical]; pathRef.current = [canonical];
setHistory([canonical]); setHistory([canonical]);
setPhase("playing"); setPhase("playing");
saveSession({ screen: "solo", soloPuzzle: p, soloHistory: [canonical], soloClicks: 0 }); saveSession({
screen: "solo",
soloPuzzle: p,
soloHistory: [canonical],
soloClicks: 0,
});
} }
const navigate = useCallback(async (t: string) => { const navigate = useCallback(
async (t: string) => {
if (loadingRef.current || gameEndedRef.current) return; if (loadingRef.current || gameEndedRef.current) return;
clicksRef.current += 1; clicksRef.current += 1;
setClicksDisplay(clicksRef.current); setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
const canonical = await loadArticle(t); const canonical = await loadArticle(t);
if (!canonical) return; if (!canonical) return;
@@ -71,22 +85,40 @@ export function useSoloGame() {
setHistory(newPath); setHistory(newPath);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) { if (
puzzle &&
normalizeTitle(canonical) === normalizeTitle(puzzle.target)
) {
timer.stop(); timer.stop();
gameEndedRef.current = true; gameEndedRef.current = true;
setPhase("won"); setPhase("won");
clearSession(); clearSession();
} else { } else {
saveSession({ screen: "solo", soloPuzzle: puzzle ?? undefined, soloHistory: newPath, soloClicks: clicksRef.current }); saveSession({
screen: "solo",
soloPuzzle: puzzle ?? undefined,
soloHistory: newPath,
soloClicks: clicksRef.current,
});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [puzzle]); },
[puzzle],
);
const goBack = useCallback(async () => { const goBack = useCallback(async () => {
if (loadingRef.current || gameEndedRef.current || pathRef.current.length <= 1) return; if (
loadingRef.current ||
gameEndedRef.current ||
pathRef.current.length <= 1
)
return;
clicksRef.current += 1; clicksRef.current += 1;
setClicksDisplay(clicksRef.current); setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
const newPath = pathRef.current.slice(0, -1); const newPath = pathRef.current.slice(0, -1);
const canonical = await loadArticle(newPath[newPath.length - 1]); const canonical = await loadArticle(newPath[newPath.length - 1]);
@@ -99,11 +131,14 @@ export function useSoloGame() {
function reset() { function reset() {
timer.reset(); timer.reset();
clicksRef.current = 0; setClicksDisplay(0); clicksRef.current = 0;
pathRef.current = []; setHistory([]); setClicksDisplay(0);
pathRef.current = [];
setHistory([]);
timerStartedRef.current = false; timerStartedRef.current = false;
gameEndedRef.current = false; gameEndedRef.current = false;
setHtml(""); setTitle(""); setHtml("");
setTitle("");
setPuzzle(null); setPuzzle(null);
setPhase("setup"); setPhase("setup");
setLoadError(null); setLoadError(null);
@@ -111,9 +146,14 @@ export function useSoloGame() {
} }
// Expose une fonction pour restaurer une session sauvegardée // Expose une fonction pour restaurer une session sauvegardée
async function restore(savedPuzzle: Puzzle, savedHistory: string[], savedClicks: number) { async function restore(
savedPuzzle: Puzzle,
savedHistory: string[],
savedClicks: number,
) {
setPuzzle(savedPuzzle); setPuzzle(savedPuzzle);
clicksRef.current = savedClicks; setClicksDisplay(savedClicks); clicksRef.current = savedClicks;
setClicksDisplay(savedClicks);
const lastTitle = savedHistory[savedHistory.length - 1]; const lastTitle = savedHistory[savedHistory.length - 1];
const canonical = await loadArticle(lastTitle); const canonical = await loadArticle(lastTitle);
if (!canonical) return false; if (!canonical) return false;
@@ -126,18 +166,26 @@ export function useSoloGame() {
} }
return { return {
phase, puzzle, html, title, loading, loadError, history, phase,
clicks: clicksDisplay, elapsed: timer.elapsed, puzzle,
html,
title,
loading,
loadError,
history,
clicks: clicksDisplay,
elapsed: timer.elapsed,
canGoBack: pathRef.current.length > 1, canGoBack: pathRef.current.length > 1,
start, navigate, goBack, reset, restore, start,
navigate,
goBack,
reset,
restore,
retryLoad: () => title && loadArticle(title), retryLoad: () => title && loadArticle(title),
}; };
} }
export function useSoloKeyboard( export function useSoloKeyboard(active: boolean, goBack: () => void) {
active: boolean,
goBack: () => void,
) {
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
+6 -1
View File
@@ -39,7 +39,12 @@ export function useTimer() {
const stop = useCallback(() => stopRef.current(), []); const stop = useCallback(() => stopRef.current(), []);
const reset = useCallback(() => resetRef.current(), []); const reset = useCallback(() => resetRef.current(), []);
useEffect(() => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }, []); useEffect(
() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
},
[],
);
return { elapsed, start, stop, reset }; return { elapsed, start, stop, reset };
} }
+3 -1
View File
@@ -19,5 +19,7 @@ export async function saveGame(data: {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} catch { /* silencieux - pas de compte ou hors ligne */ } } catch {
/* silencieux - pas de compte ou hors ligne */
}
} }
+5 -2
View File
@@ -83,7 +83,9 @@ async function fetchRandomCandidates(): Promise<string[]> {
}); });
const res = await fetch(`${WIKI_API_BASE}?${params}`); const res = await fetch(`${WIKI_API_BASE}?${params}`);
if (!res.ok) throw new Error("Erreur reseau"); if (!res.ok) throw new Error("Erreur reseau");
const data = await res.json() as { query: { pages: Record<string, WikiPageInfo> } }; const data = (await res.json()) as {
query: { pages: Record<string, WikiPageInfo> };
};
return Object.values(data.query.pages) return Object.values(data.query.pages)
.filter(isGoodArticle) .filter(isGoodArticle)
.map((p) => p.title); .map((p) => p.title);
@@ -96,7 +98,8 @@ export async function pickTwoArticles(): Promise<Puzzle> {
const batch = await fetchRandomCandidates(); const batch = await fetchRandomCandidates();
for (const title of batch) { for (const title of batch) {
if (!collected.includes(title)) collected.push(title); if (!collected.includes(title)) collected.push(title);
if (collected.length >= 2) return { start: collected[0], target: collected[1] }; if (collected.length >= 2)
return { start: collected[0], target: collected[1] };
} }
} }
} catch { } catch {