style: apply prettier formatting across codebase
This commit is contained in:
+8
-2
@@ -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
@@ -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'administration</p>
|
<p className="text-xs text-[#555] mt-0.5">
|
||||||
|
WikiRush - panneau d'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'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'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'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'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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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'article de départ jusqu'à l'article cible en cliquant sur les liens Wikipedia. Plus tu es rapide, mieux c'est !
|
Tu as <span className="text-[#f0f0f0] font-bold">2 minutes</span> pour
|
||||||
|
naviguer de l'article de départ jusqu'à l'article cible
|
||||||
|
en cliquant sur les liens Wikipedia. Plus tu es rapide, mieux
|
||||||
|
c'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'objectif était d'atteindre{" "}
|
L'objectif était d'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>
|
||||||
|
|||||||
@@ -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
@@ -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'instant.</p>
|
<p className="text-sm text-[#888] text-center py-4">
|
||||||
|
Aucun résultat pour l'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'accueil</button>
|
<button className={btnGhost} onClick={onBack}>
|
||||||
|
Retour à l'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
@@ -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'hôte...</p>
|
<p className="text-xs sm:text-sm text-[#888] text-center animate-pulse-slow">
|
||||||
|
En attente de l'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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'article cible en cliquant uniquement sur les liens !
|
Deux articles aléatoires seront choisis. Atteins l'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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user