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: "1 000 parties et plus", range: "1 000+" },
|
||||
{ emoji: "🔥", label: "5 000 parties et plus", range: "5 000+" },
|
||||
{ emoji: "🚀", label: "10 000 parties et plus", range: "10 000+" },
|
||||
{
|
||||
emoji: "🚀",
|
||||
label: "10 000 parties et plus",
|
||||
range: "10 000+",
|
||||
},
|
||||
].map(({ emoji, label, range }) => (
|
||||
<div key={range} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="text-xl w-7 text-center shrink-0">{emoji}</span>
|
||||
<span className="text-xl w-7 text-center shrink-0">
|
||||
{emoji}
|
||||
</span>
|
||||
<span className="text-sm text-[#aaa] flex-1">{label}</span>
|
||||
<span className="text-xs font-mono text-[#555]">{range}</span>
|
||||
</div>
|
||||
|
||||
+263
-68
@@ -62,15 +62,21 @@ function MiniBarChart({ data }: { data: ActivityDay[] }) {
|
||||
<div className="flex items-end gap-1 h-16">
|
||||
{data.map((d) => {
|
||||
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 (
|
||||
<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
|
||||
className="w-full bg-[#7c3aed]/60 rounded-sm group-hover:bg-[#7c3aed] transition-colors"
|
||||
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">
|
||||
{day} — {d.count}
|
||||
{day} - {d.count}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -83,19 +89,32 @@ export default function AdminPage() {
|
||||
const [data, setData] = useState<AdminStats | null>(null);
|
||||
const [dailyPuzzles, setDailyPuzzles] = useState<DailyPuzzle[] | null>(null);
|
||||
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
|
||||
const [puzzleForm, setPuzzleForm] = useState({ date: "", startArticle: "", targetArticle: "" });
|
||||
const [puzzleForm, setPuzzleForm] = useState({
|
||||
date: "",
|
||||
startArticle: "",
|
||||
targetArticle: "",
|
||||
});
|
||||
const [editingPuzzle, setEditingPuzzle] = useState<DailyPuzzle | null>(null);
|
||||
const [puzzleSaving, setPuzzleSaving] = useState(false);
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -139,7 +158,10 @@ export default function AdminPage() {
|
||||
await fetch(`/api/admin/daily/${editingPuzzle.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ startArticle: puzzleForm.startArticle, targetArticle: puzzleForm.targetArticle }),
|
||||
body: JSON.stringify({
|
||||
startArticle: puzzleForm.startArticle,
|
||||
targetArticle: puzzleForm.targetArticle,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/admin/daily", {
|
||||
@@ -158,15 +180,22 @@ export default function AdminPage() {
|
||||
|
||||
function startEditPuzzle(p: DailyPuzzle) {
|
||||
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 tabBtn = (active: boolean) =>
|
||||
`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 (
|
||||
<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>
|
||||
<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>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <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>}
|
||||
{loading && (
|
||||
<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 */}
|
||||
{confirmDelete && (
|
||||
<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">
|
||||
<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">
|
||||
<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
|
||||
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
|
||||
</button>
|
||||
@@ -207,12 +262,31 @@ export default function AdminPage() {
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button className={tabBtn(tab === "overview")} 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
|
||||
className={tabBtn(tab === "overview")}
|
||||
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 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>
|
||||
|
||||
{/* Overview */}
|
||||
@@ -220,41 +294,80 @@ export default function AdminPage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<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: "Parties aujourd'hui", value: data.todayGames.toLocaleString("fr-FR") },
|
||||
{
|
||||
label: "Utilisateurs",
|
||||
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 }) => (
|
||||
<div key={label} className={card}>
|
||||
<p className="text-[10px] text-[#555] uppercase tracking-widest mb-1">{label}</p>
|
||||
<p className="text-3xl font-black text-[#7c3aed]">{value}</p>
|
||||
<p className="text-[10px] text-[#555] uppercase tracking-widest mb-1">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-3xl font-black text-[#7c3aed]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Graphique activité */}
|
||||
<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} />
|
||||
<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]">aujourd'hui</span>
|
||||
<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]">
|
||||
aujourd'hui
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modes */}
|
||||
<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">
|
||||
{data.modeStats.sort((a, b) => b._count.id - a._count.id).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]">
|
||||
<span>{m._count.id.toLocaleString("fr-FR")} parties</span>
|
||||
{m._avg.clicks !== null && <span>~{Math.round(m._avg.clicks)} clics</span>}
|
||||
{m._avg.timeSeconds !== null && <span>~{fmt(m._avg.timeSeconds)}</span>}
|
||||
{data.modeStats
|
||||
.sort((a, b) => b._count.id - a._count.id)
|
||||
.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]">
|
||||
<span>
|
||||
{m._count.id.toLocaleString("fr-FR")} parties
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,20 +376,33 @@ export default function AdminPage() {
|
||||
{/* Users */}
|
||||
{tab === "users" && (
|
||||
<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">
|
||||
{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">
|
||||
<p className="font-semibold text-sm truncate flex items-center gap-2">
|
||||
{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 className="text-xs text-[#555] truncate">{u.email}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 hidden sm:block">
|
||||
<p className="text-xs text-[#888]">{u._count.games} parties</p>
|
||||
<p className="text-[10px] text-[#555]">{new Date(u.createdAt).toLocaleDateString("fr-FR")}</p>
|
||||
<p className="text-xs text-[#888]">
|
||||
{u._count.games} parties
|
||||
</p>
|
||||
<p className="text-[10px] text-[#555]">
|
||||
{new Date(u.createdAt).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
@@ -286,7 +412,13 @@ export default function AdminPage() {
|
||||
{u.banned ? "Débannir" : "Bannir"}
|
||||
</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"
|
||||
>
|
||||
Supprimer
|
||||
@@ -301,25 +433,42 @@ export default function AdminPage() {
|
||||
{/* Games */}
|
||||
{tab === "games" && (
|
||||
<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">
|
||||
{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">
|
||||
<p className="text-sm truncate">
|
||||
<span className="font-semibold">{g.user.name}</span>
|
||||
<span className="text-[#555] mx-1">—</span>
|
||||
<span className="text-[#888] text-xs">{g.startArticle} → {g.targetArticle}</span>
|
||||
<span className="text-[#555] mx-1">-</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 className="text-[10px] text-[#555]">{new Date(g.playedAt).toLocaleString("fr-FR")}</p>
|
||||
</div>
|
||||
<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"}
|
||||
</span>
|
||||
<span className="text-xs text-[#888] capitalize hidden sm:inline">{g.mode}</span>
|
||||
<span className="text-xs text-[#555]">{g.clicks} clics</span>
|
||||
<span className="text-xs text-[#555] hidden sm:inline">{fmt(g.timeSeconds)}</span>
|
||||
<span className="text-xs text-[#888] capitalize hidden sm:inline">
|
||||
{g.mode}
|
||||
</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>
|
||||
))}
|
||||
@@ -333,7 +482,9 @@ export default function AdminPage() {
|
||||
{/* Formulaire */}
|
||||
<div className={card}>
|
||||
<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>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{!editingPuzzle && (
|
||||
@@ -341,7 +492,9 @@ export default function AdminPage() {
|
||||
className={inputCls}
|
||||
type="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)"
|
||||
/>
|
||||
)}
|
||||
@@ -349,27 +502,53 @@ export default function AdminPage() {
|
||||
className={inputCls}
|
||||
type="text"
|
||||
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)"
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="text"
|
||||
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)"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{puzzleSaving ? "Enregistrement..." : editingPuzzle ? "Modifier" : "Créer"}
|
||||
{puzzleSaving
|
||||
? "Enregistrement..."
|
||||
: editingPuzzle
|
||||
? "Modifier"
|
||||
: "Créer"}
|
||||
</button>
|
||||
{editingPuzzle && (
|
||||
<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]"
|
||||
>
|
||||
Annuler
|
||||
@@ -382,14 +561,24 @@ export default function AdminPage() {
|
||||
{/* Liste */}
|
||||
{dailyPuzzles && (
|
||||
<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">
|
||||
{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">
|
||||
<p className="text-sm font-semibold">{p.date}</p>
|
||||
<p className="text-xs text-[#888] truncate">{p.startArticle} → {p.targetArticle}</p>
|
||||
<p className="text-[10px] text-[#555]">{p._count.results} résultat{p._count.results > 1 ? "s" : ""}</p>
|
||||
<p className="text-xs text-[#888] truncate">
|
||||
{p.startArticle} → {p.targetArticle}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#555]">
|
||||
{p._count.results} résultat
|
||||
{p._count.results > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
@@ -399,7 +588,13 @@ export default function AdminPage() {
|
||||
Modifier
|
||||
</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"
|
||||
>
|
||||
Supprimer
|
||||
|
||||
@@ -3,29 +3,59 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const FORBIDDEN_NAMESPACES = [
|
||||
"Fichier:", "File:", "Wikipedia:", "Aide:", "Help:", "Categorie:", "Category:",
|
||||
"Discussion:", "Talk:", "Utilisateur:", "User:", "Special:", "Sp\u00e9cial:",
|
||||
"Portail:", "Portal:", "Mod\u00e8le:", "Template:", "Projet:", "WP:",
|
||||
"Fichier:",
|
||||
"File:",
|
||||
"Wikipedia:",
|
||||
"Aide:",
|
||||
"Help:",
|
||||
"Categorie:",
|
||||
"Category:",
|
||||
"Discussion:",
|
||||
"Talk:",
|
||||
"Utilisateur:",
|
||||
"User:",
|
||||
"Special:",
|
||||
"Sp\u00e9cial:",
|
||||
"Portail:",
|
||||
"Portal:",
|
||||
"Mod\u00e8le:",
|
||||
"Template:",
|
||||
"Projet:",
|
||||
"WP:",
|
||||
];
|
||||
|
||||
const REMOVED_SECTION_IDS = [
|
||||
"Liens_externes", "R\u00e9f\u00e9rences", "Notes", "Bibliographie",
|
||||
"Voir_aussi", "Notes_et_r\u00e9f\u00e9rences", "Sources",
|
||||
"Annexes", "Articles_connexes",
|
||||
"Liens_externes",
|
||||
"R\u00e9f\u00e9rences",
|
||||
"Notes",
|
||||
"Bibliographie",
|
||||
"Voir_aussi",
|
||||
"Notes_et_r\u00e9f\u00e9rences",
|
||||
"Sources",
|
||||
"Annexes",
|
||||
"Articles_connexes",
|
||||
];
|
||||
|
||||
function cleanWikiHtml(container: HTMLElement): void {
|
||||
container.querySelectorAll(".mw-editsection").forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(
|
||||
".ambox, .tmbox, .cmbox, .ombox, .fmbox, .hatnote, .bandeau-container, .bandeau"
|
||||
).forEach((el) => el.remove());
|
||||
container.querySelectorAll(".audio, .audiolink, audio, video").forEach((el) => el.remove());
|
||||
container
|
||||
.querySelectorAll(
|
||||
".reflist, .references, .mw-references-wrap, sup.reference, .mw-ref, .reference",
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
container
|
||||
.querySelectorAll(
|
||||
".navbox, .navbox-inner, .vertical-navbox, .catlinks, .sistersitebox, .bandeau-portail",
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
container
|
||||
.querySelectorAll(
|
||||
".ambox, .tmbox, .cmbox, .ombox, .fmbox, .hatnote, .bandeau-container, .bandeau",
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
container
|
||||
.querySelectorAll(".audio, .audiolink, audio, video")
|
||||
.forEach((el) => el.remove());
|
||||
container.querySelectorAll(".gallery").forEach((el) => el.remove());
|
||||
container.querySelectorAll("#toc, .toc").forEach((el) => el.remove());
|
||||
|
||||
@@ -68,8 +98,12 @@ export function ArticleView({
|
||||
const onNavigateRef = useRef(onNavigate);
|
||||
const disabledRef = useRef(disabled);
|
||||
|
||||
useEffect(() => { onNavigateRef.current = onNavigate; });
|
||||
useEffect(() => { disabledRef.current = disabled; });
|
||||
useEffect(() => {
|
||||
onNavigateRef.current = onNavigate;
|
||||
});
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -78,21 +112,30 @@ export function ArticleView({
|
||||
container.innerHTML = html;
|
||||
cleanWikiHtml(container);
|
||||
|
||||
container.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']").forEach((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
const path = href.replace("/wiki/", "");
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(path); } catch { decoded = path; }
|
||||
const title = decoded.replace(/_/g, " ");
|
||||
container
|
||||
.querySelectorAll<HTMLAnchorElement>("a[href^='/wiki/']")
|
||||
.forEach((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
const path = href.replace("/wiki/", "");
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(path);
|
||||
} catch {
|
||||
decoded = path;
|
||||
}
|
||||
const title = decoded.replace(/_/g, " ");
|
||||
|
||||
if (FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) || title.includes("#")) {
|
||||
if (
|
||||
FORBIDDEN_NAMESPACES.some((ns) => title.startsWith(ns)) ||
|
||||
title.includes("#")
|
||||
) {
|
||||
link.removeAttribute("href");
|
||||
return;
|
||||
}
|
||||
link.setAttribute("data-wiki-title", title);
|
||||
link.removeAttribute("href");
|
||||
return;
|
||||
}
|
||||
link.setAttribute("data-wiki-title", title);
|
||||
link.removeAttribute("href");
|
||||
link.classList.add("wiki-link");
|
||||
});
|
||||
link.classList.add("wiki-link");
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
|
||||
link.removeAttribute("href");
|
||||
@@ -100,7 +143,9 @@ export function ArticleView({
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
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;
|
||||
e.preventDefault();
|
||||
const title = target.getAttribute("data-wiki-title");
|
||||
|
||||
@@ -5,7 +5,13 @@ import { signIn } from "next-auth/react";
|
||||
|
||||
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 [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -24,30 +30,56 @@ export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSucce
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
const data = await res.json() as { error?: string };
|
||||
if (!res.ok) { setError(data.error ?? "Erreur"); return; }
|
||||
const data = (await res.json()) as { error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Erreur");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
if (result?.error) {
|
||||
setError("Email ou mot de passe incorrect");
|
||||
return;
|
||||
}
|
||||
const result = await signIn("credentials", { email, password, redirect: false });
|
||||
if (result?.error) { setError("Email ou mot de passe incorrect"); return; }
|
||||
onSuccess();
|
||||
} finally {
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-1000 p-4" 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="fixed inset-0 bg-black/70 flex items-center justify-center z-1000 p-4"
|
||||
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">
|
||||
{(["login", "register"] as Mode[]).map((m) => (
|
||||
<button
|
||||
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]"}`}
|
||||
onClick={() => { setMode(m); setError(null); }}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{m === "login" ? "Connexion" : "Inscription"}
|
||||
</button>
|
||||
@@ -56,24 +88,61 @@ export function AuthModal({ onClose, onSuccess }: { onClose: () => void; onSucce
|
||||
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||
{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 className={inputCls} type="password" placeholder="Mot de passe" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
|
||||
<input
|
||||
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 && (
|
||||
<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}>
|
||||
{loading ? "Chargement..." : mode === "login" ? "Se connecter" : "Créer un compte"}
|
||||
<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}
|
||||
>
|
||||
{loading
|
||||
? "Chargement..."
|
||||
: mode === "login"
|
||||
? "Se connecter"
|
||||
: "Créer un compte"}
|
||||
</button>
|
||||
{mode === "register" && (
|
||||
<p className="text-xs text-[#555] text-center leading-relaxed">
|
||||
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é
|
||||
</a>.
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
+192
-109
@@ -15,122 +15,186 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
|
||||
const game = useBlitzGame();
|
||||
const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed();
|
||||
|
||||
|
||||
const btnPrimary = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
|
||||
const btnGhost = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
|
||||
const btnPrimary =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
|
||||
const btnGhost =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
|
||||
|
||||
const danger = game.timeLeft < 30;
|
||||
|
||||
if (game.phase === "setup") return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center justify-center px-4 py-8 gap-5 max-w-sm mx-auto text-center">
|
||||
<button className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer" onClick={onBack}>
|
||||
Retour
|
||||
</button>
|
||||
<div className="text-5xl">⚡</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Mode Blitz</h2>
|
||||
<p className="text-[#888] text-sm sm:text-base leading-relaxed">
|
||||
Tu as <span className="text-[#f0f0f0] font-bold">2 minutes</span>{" "}pour 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>
|
||||
<button
|
||||
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]"}`}
|
||||
>
|
||||
<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]"}`}>
|
||||
{searchAllowed ? "Autorisée" : "Bloquée"}
|
||||
</span>
|
||||
</button>
|
||||
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
|
||||
{game.loading ? "Préparation..." : "Lancer le chrono !"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (game.phase === "won") return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-5">
|
||||
<div className="text-5xl sm:text-6xl leading-none">🎉</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Article trouvé !</h2>
|
||||
{game.puzzle && (
|
||||
<p className="text-[#888] text-sm">
|
||||
<span className="text-[#f0f0f0] font-semibold">{game.puzzle.start}</span>
|
||||
{" → "}
|
||||
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-8 justify-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">{fmtLeft(game.timeLeft)}</span>
|
||||
<span className="text-xs text-[#888]">temps restant</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">{game.clicks}</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">{game.history.length}</span>
|
||||
<span className="text-xs text-[#888]">articles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1a1a] rounded-xl px-4 py-3 flex flex-wrap gap-1 text-xs text-[#888] text-left max-h-48 overflow-y-auto">
|
||||
{game.history.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span className={t === game.puzzle?.target ? "text-[#7c3aed] font-bold" : ""}>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
|
||||
Rejouer
|
||||
</button>
|
||||
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button>
|
||||
</div>
|
||||
if (game.phase === "setup")
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center justify-center px-4 py-8 gap-5 max-w-sm mx-auto text-center">
|
||||
<button
|
||||
className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
<div className="text-5xl">⚡</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Mode Blitz</h2>
|
||||
<p className="text-[#888] text-sm sm:text-base leading-relaxed">
|
||||
Tu as <span className="text-[#f0f0f0] font-bold">2 minutes</span> pour
|
||||
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>
|
||||
<button
|
||||
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]"}`}
|
||||
>
|
||||
<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]"}`}
|
||||
>
|
||||
{searchAllowed ? "Autorisée" : "Bloquée"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={btnPrimary}
|
||||
onClick={game.start}
|
||||
disabled={game.loading}
|
||||
>
|
||||
{game.loading ? "Préparation..." : "Lancer le chrono !"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
if (game.phase === "lost") return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-5">
|
||||
<div className="text-5xl sm:text-6xl leading-none">⏱️</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Temps écoulé !</h2>
|
||||
{game.puzzle && (
|
||||
<p className="text-[#888] text-sm">
|
||||
L'objectif était d'atteindre{" "}
|
||||
<span className="text-[#7c3aed] font-semibold">{game.puzzle.target}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-8 justify-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-red-400">{game.clicks}</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
if (game.phase === "won")
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-5">
|
||||
<div className="text-5xl sm:text-6xl leading-none">🎉</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Article trouvé !</h2>
|
||||
{game.puzzle && (
|
||||
<p className="text-[#888] text-sm">
|
||||
<span className="text-[#f0f0f0] font-semibold">
|
||||
{game.puzzle.start}
|
||||
</span>
|
||||
{" → "}
|
||||
<span className="text-[#7c3aed] font-semibold">
|
||||
{game.puzzle.target}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-8 justify-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">
|
||||
{fmtLeft(game.timeLeft)}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">temps restant</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">
|
||||
{game.clicks}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-[#7c3aed]">
|
||||
{game.history.length}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">articles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-red-400">{game.history.length}</span>
|
||||
<span className="text-xs text-[#888]">articles visités</span>
|
||||
</div>
|
||||
</div>
|
||||
{game.history.length > 0 && (
|
||||
<div className="bg-[#1a1a1a] rounded-xl px-4 py-3 flex flex-wrap gap-1 text-xs text-[#888] text-left max-h-48 overflow-y-auto">
|
||||
{game.history.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span>{t}</span>
|
||||
<span
|
||||
className={
|
||||
t === game.puzzle?.target ? "text-[#7c3aed] font-bold" : ""
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<button className={btnPrimary} onClick={game.start} disabled={game.loading}>
|
||||
Réessayer
|
||||
</button>
|
||||
<button className={btnGhost} onClick={() => { game.reset(); onBack(); }}>Accueil</button>
|
||||
<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">
|
||||
<button
|
||||
className={btnPrimary}
|
||||
onClick={game.start}
|
||||
disabled={game.loading}
|
||||
>
|
||||
Rejouer
|
||||
</button>
|
||||
<button
|
||||
className={btnGhost}
|
||||
onClick={() => {
|
||||
game.reset();
|
||||
onBack();
|
||||
}}
|
||||
>
|
||||
Accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
if (game.phase === "lost")
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-sm sm:max-w-md text-center flex flex-col gap-5">
|
||||
<div className="text-5xl sm:text-6xl leading-none">⏱️</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Temps écoulé !</h2>
|
||||
{game.puzzle && (
|
||||
<p className="text-[#888] text-sm">
|
||||
L'objectif était d'atteindre{" "}
|
||||
<span className="text-[#7c3aed] font-semibold">
|
||||
{game.puzzle.target}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-8 justify-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-red-400">
|
||||
{game.clicks}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-4xl font-black text-red-400">
|
||||
{game.history.length}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">articles visités</span>
|
||||
</div>
|
||||
</div>
|
||||
{game.history.length > 0 && (
|
||||
<div className="bg-[#1a1a1a] rounded-xl px-4 py-3 flex flex-wrap gap-1 text-xs text-[#888] text-left max-h-48 overflow-y-auto">
|
||||
{game.history.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<button
|
||||
className={btnPrimary}
|
||||
onClick={game.start}
|
||||
disabled={game.loading}
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
<button
|
||||
className={btnGhost}
|
||||
onClick={() => {
|
||||
game.reset();
|
||||
onBack();
|
||||
}}
|
||||
>
|
||||
Accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Playing
|
||||
return (
|
||||
@@ -139,18 +203,27 @@ export function BlitzScreen({ onBack }: { onBack: () => void }) {
|
||||
<div className="article-container">
|
||||
{game.title && <h1 className="article-title">{game.title}</h1>}
|
||||
{game.loading && (
|
||||
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div>
|
||||
<div className="flex items-center gap-3 py-10 px-4 text-[#888]">
|
||||
<div className="spinner" /> Chargement...
|
||||
</div>
|
||||
)}
|
||||
{game.loadError && (
|
||||
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
|
||||
<p className="text-[#888] text-sm">{game.loadError}</p>
|
||||
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer" onClick={() => game.retryLoad()}>
|
||||
<button
|
||||
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer"
|
||||
onClick={() => game.retryLoad()}
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!game.loading && !game.loadError && game.html && (
|
||||
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} />
|
||||
<ArticleView
|
||||
html={game.html}
|
||||
onNavigate={game.navigate}
|
||||
disabled={game.loading}
|
||||
/>
|
||||
)}
|
||||
</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 gap-3">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider text-[#888]">Clics</span>
|
||||
<span className="text-sm font-black tabular-nums">{game.clicks}</span>
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider text-[#888]">
|
||||
Clics
|
||||
</span>
|
||||
<span className="text-sm font-black tabular-nums">
|
||||
{game.clicks}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className={`text-2xl sm:text-3xl font-black tabular-nums transition-colors ${danger ? "text-red-400 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)}
|
||||
</div>
|
||||
|
||||
{game.puzzle && (
|
||||
<div className="text-right">
|
||||
<div className="text-[8px] font-bold uppercase tracking-wider text-[#888]">Cible</div>
|
||||
<div className="text-xs font-bold text-[#7c3aed] max-w-28 truncate">{game.puzzle.target}</div>
|
||||
<div className="text-[8px] font-bold uppercase tracking-wider text-[#888]">
|
||||
Cible
|
||||
</div>
|
||||
<div className="text-xs font-bold text-[#7c3aed] max-w-28 truncate">
|
||||
{game.puzzle.target}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,25 @@
|
||||
|
||||
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 (
|
||||
<div className="flex items-center flex-nowrap overflow-x-auto gap-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{history.map((title, i) => (
|
||||
<span key={i} className="flex items-center whitespace-nowrap shrink-0">
|
||||
{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}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
+146
-44
@@ -9,16 +9,32 @@ import { ShareBar } from "./ShareBar";
|
||||
|
||||
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 breadcrumbEndRef = useRef<HTMLDivElement>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
|
||||
const [loadingLb, setLoadingLb] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
|
||||
breadcrumbEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "end",
|
||||
});
|
||||
}, [game.history]);
|
||||
|
||||
function fetchLeaderboard() {
|
||||
@@ -30,47 +46,78 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}, [game.phase]);
|
||||
|
||||
const btnPrimary = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
|
||||
const btnGhost = "w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
|
||||
const btnPrimary =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
|
||||
const btnGhost =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
|
||||
|
||||
// Loading initial
|
||||
if (game.phase === "loading") return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] flex items-center justify-center">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
if (game.phase === "loading")
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] flex items-center justify-center">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Erreur initiale
|
||||
if (game.loadError) return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] flex flex-col items-center justify-center gap-4 px-4">
|
||||
<p className="text-[#888]">{game.loadError}</p>
|
||||
<button className={btnGhost} style={{ width: "auto", padding: "0 20px" }} onClick={onBack}>Retour</button>
|
||||
</div>
|
||||
);
|
||||
if (game.loadError)
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] flex flex-col items-center justify-center gap-4 px-4">
|
||||
<p className="text-[#888]">{game.loadError}</p>
|
||||
<button
|
||||
className={btnGhost}
|
||||
style={{ width: "auto", padding: "0 20px" }}
|
||||
onClick={onBack}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Résultat (won / gave_up / already_played)
|
||||
if (game.phase === "won" || game.phase === "gave_up" || game.phase === "already_played") {
|
||||
if (
|
||||
game.phase === "won" ||
|
||||
game.phase === "gave_up" ||
|
||||
game.phase === "already_played"
|
||||
) {
|
||||
const result = game.myResult;
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col max-w-lg mx-auto px-4 py-8 gap-6">
|
||||
<button className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer" onClick={onBack}>
|
||||
<button
|
||||
className="self-start min-h-9 px-3 rounded-lg text-sm font-semibold bg-[#242424] border border-[#2e2e2e] hover:bg-[#1a1a1a] cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-bold text-[#888] uppercase tracking-widest mb-1">Défi du jour - {game.puzzle?.date}</div>
|
||||
<div className="text-xs font-bold text-[#888] uppercase tracking-widest mb-1">
|
||||
Défi du jour - {game.puzzle?.date}
|
||||
</div>
|
||||
<div className="text-2xl sm:text-3xl font-black mb-1">
|
||||
{game.phase === "won" ? "🎉 Réussi !" : game.phase === "gave_up" ? "😔 Abandonné" : "✅ Déjà joué"}
|
||||
{game.phase === "won"
|
||||
? "🎉 Réussi !"
|
||||
: game.phase === "gave_up"
|
||||
? "😔 Abandonné"
|
||||
: "✅ Déjà joué"}
|
||||
</div>
|
||||
<div className="text-sm text-[#888]">
|
||||
<span className="text-[#f0f0f0] font-semibold">{game.puzzle?.startArticle}</span>
|
||||
<span className="text-[#f0f0f0] font-semibold">
|
||||
{game.puzzle?.startArticle}
|
||||
</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>
|
||||
|
||||
@@ -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="flex justify-center gap-8">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -91,7 +142,15 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
|
||||
{result.path.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span className={i === result.path.length - 1 && result.won ? "text-[#16a34a] font-semibold" : ""}>{t}</span>
|
||||
<span
|
||||
className={
|
||||
i === result.path.length - 1 && result.won
|
||||
? "text-[#16a34a] font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -101,18 +160,33 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
|
||||
|
||||
{/* Classement du jour */}
|
||||
<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 ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
{leaderboard.map((entry, i) => (
|
||||
<div key={i} className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl border ${entry.userId === currentUserId ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}>
|
||||
<span className="text-lg w-7 text-center shrink-0">{MEDALS[i] ?? `#${entry.rank}`}</span>
|
||||
<span className="flex-1 font-semibold text-sm truncate">{entry.name}</span>
|
||||
<span className="text-xs text-[#888] tabular-nums shrink-0">{entry.clicks} clics · {fmt(entry.timeSeconds)}</span>
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl border ${entry.userId === currentUserId ? "border-[#7c3aed] bg-[#7c3aed]/8" : "border-[#2e2e2e] bg-[#1a1a1a]"}`}
|
||||
>
|
||||
<span className="text-lg w-7 text-center shrink-0">
|
||||
{MEDALS[i] ?? `#${entry.rank}`}
|
||||
</span>
|
||||
<span className="flex-1 font-semibold text-sm truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-xs text-[#888] tabular-nums shrink-0">
|
||||
{entry.clicks} clics · {fmt(entry.timeSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -120,9 +194,13 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -134,18 +212,27 @@ export function DailyScreen({ onBack, currentUserId }: { onBack: () => void; cur
|
||||
<div className="article-container">
|
||||
{game.title && <h1 className="article-title">{game.title}</h1>}
|
||||
{game.loading && (
|
||||
<div className="flex items-center gap-3 py-10 px-4 text-[#888]"><div className="spinner" /> Chargement...</div>
|
||||
<div className="flex items-center gap-3 py-10 px-4 text-[#888]">
|
||||
<div className="spinner" /> Chargement...
|
||||
</div>
|
||||
)}
|
||||
{game.loadError && (
|
||||
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
|
||||
<p className="text-[#888] text-sm">{game.loadError}</p>
|
||||
<button className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer" onClick={() => game.retryLoad()}>
|
||||
<button
|
||||
className="min-h-11 px-5 rounded-xl text-sm font-semibold bg-[#2563eb] text-white cursor-pointer"
|
||||
onClick={() => game.retryLoad()}
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!game.loading && !game.loadError && game.html && (
|
||||
<ArticleView html={game.html} onNavigate={game.navigate} disabled={game.loading} />
|
||||
<ArticleView
|
||||
html={game.html}
|
||||
onNavigate={game.navigate}
|
||||
disabled={game.loading}
|
||||
/>
|
||||
)}
|
||||
</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="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] font-bold uppercase tracking-widest text-[#888] shrink-0">🗓 Défi du jour · cible</span>
|
||||
<span className="text-xs font-black text-[#7c3aed] truncate">{game.puzzle?.targetArticle}</span>
|
||||
<span className="text-[8px] font-bold uppercase tracking-widest text-[#888] shrink-0">
|
||||
🗓 Défi du jour · cible
|
||||
</span>
|
||||
<span className="text-xs font-black text-[#7c3aed] truncate">
|
||||
{game.puzzle?.targetArticle}
|
||||
</span>
|
||||
</div>
|
||||
<Breadcrumbs history={game.history} endRef={breadcrumbEndRef} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-col items-center gap-0.5 min-w-10">
|
||||
<span className="text-[8px] font-bold tracking-wider text-[#888] uppercase">Clics</span>
|
||||
<span className="text-xs font-black tabular-nums">{game.clicks}</span>
|
||||
<span className="text-[8px] font-bold tracking-wider text-[#888] uppercase">
|
||||
Clics
|
||||
</span>
|
||||
<span className="text-xs font-black tabular-nums">
|
||||
{game.clicks}
|
||||
</span>
|
||||
</div>
|
||||
{game.canGoBack && (
|
||||
<button className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] disabled:opacity-50 cursor-pointer" onClick={game.goBack} disabled={game.loading}>
|
||||
<button
|
||||
className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] disabled:opacity-50 cursor-pointer"
|
||||
onClick={game.goBack}
|
||||
disabled={game.loading}
|
||||
>
|
||||
← +1
|
||||
</button>
|
||||
)}
|
||||
<button className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-red-950/40 border border-red-900 text-red-400 hover:bg-red-900/40 cursor-pointer" onClick={game.giveUp}>
|
||||
<button
|
||||
className="min-h-8 px-2 rounded-lg text-xs font-semibold bg-red-950/40 border border-red-900 text-red-400 hover:bg-red-900/40 cursor-pointer"
|
||||
onClick={game.giveUp}
|
||||
>
|
||||
Abandonner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+226
-111
@@ -25,8 +25,21 @@ type GameScreenProps = {
|
||||
};
|
||||
|
||||
export function GameScreen({
|
||||
room, playerId, html, title, loading, loadError, history, clicks, elapsed,
|
||||
countdown, onNavigate, onRetry, onNextRound, onResetGame, onSurrender,
|
||||
room,
|
||||
playerId,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
history,
|
||||
clicks,
|
||||
elapsed,
|
||||
countdown,
|
||||
onNavigate,
|
||||
onRetry,
|
||||
onNextRound,
|
||||
onResetGame,
|
||||
onSurrender,
|
||||
}: GameScreenProps) {
|
||||
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
|
||||
useCtrlFBlock(room.searchAllowed);
|
||||
@@ -34,125 +47,198 @@ export function GameScreen({
|
||||
const isHost = myPlayer?.isHost ?? false;
|
||||
const myFinished = myPlayer?.hasWon || myPlayer?.hasSurrendered;
|
||||
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(() => {
|
||||
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
|
||||
breadcrumbEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "end",
|
||||
});
|
||||
}, [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 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";
|
||||
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 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
|
||||
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="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">
|
||||
{winner ? (
|
||||
<>
|
||||
<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">Manche terminée !</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-xs sm:text-sm text-[#888]">
|
||||
<span className="text-[#f0f0f0]">{room.startArticle}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="text-[#7c3aed] font-bold">{room.targetArticle}</span>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{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]"}`}>
|
||||
<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="flex-1 font-semibold text-sm truncate">{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>
|
||||
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="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">
|
||||
{winner ? (
|
||||
<>
|
||||
<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>
|
||||
{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">
|
||||
{p.path.map((t, j) => (
|
||||
<span key={j} className="flex items-center gap-0.5">
|
||||
{j > 0 && <span className="text-[#444]">›</span>}
|
||||
<span className={
|
||||
j === 0 ? "text-[#555]" :
|
||||
j === p.path.length - 1 && p.hasWon ? "text-green-400 font-semibold" :
|
||||
j === p.path.length - 1 && p.hasSurrendered ? "text-[#888]" :
|
||||
"text-[#888]"
|
||||
}>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[#555] ml-1">({p.path.length - 1} clic{p.path.length - 1 > 1 ? "s" : ""})</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-center text-xs text-[#555]">
|
||||
Manche {room.round}/{room.totalRounds}
|
||||
</div>
|
||||
{isHost ? (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{room.round < room.totalRounds
|
||||
? <button className={btnPrimary} onClick={onNextRound}>Manche suivante</button>
|
||||
: <button className={btnPrimary} onClick={onResetGame}>Partie terminée — Recommencer</button>
|
||||
}
|
||||
{room.round < room.totalRounds && (
|
||||
<button className={btnGhost} onClick={onResetGame}>Arrêter la partie</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-base sm:text-lg font-bold">
|
||||
Manche terminée !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs sm:text-sm text-[#888] text-center animate-pulse-slow">En attente de l'hôte...</p>
|
||||
)}
|
||||
<div className="text-center text-xs sm:text-sm text-[#888]">
|
||||
<span className="text-[#f0f0f0]">{room.startArticle}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="text-[#7c3aed] font-bold">
|
||||
{room.targetArticle}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{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]"}`}
|
||||
>
|
||||
<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="flex-1 font-semibold text-sm truncate">
|
||||
{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>
|
||||
{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">
|
||||
{p.path.map((t, j) => (
|
||||
<span key={j} className="flex items-center gap-0.5">
|
||||
{j > 0 && <span className="text-[#444]">›</span>}
|
||||
<span
|
||||
className={
|
||||
j === 0
|
||||
? "text-[#555]"
|
||||
: j === p.path.length - 1 && p.hasWon
|
||||
? "text-green-400 font-semibold"
|
||||
: j === p.path.length - 1 && p.hasSurrendered
|
||||
? "text-[#888]"
|
||||
: "text-[#888]"
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[#555] ml-1">
|
||||
({p.path.length - 1} clic
|
||||
{p.path.length - 1 > 1 ? "s" : ""})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-center text-xs text-[#555]">
|
||||
Manche {room.round}/{room.totalRounds}
|
||||
</div>
|
||||
{isHost ? (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{room.round < room.totalRounds ? (
|
||||
<button className={btnPrimary} onClick={onNextRound}>
|
||||
Manche suivante
|
||||
</button>
|
||||
) : (
|
||||
<button className={btnPrimary} onClick={onResetGame}>
|
||||
Partie terminée - Recommencer
|
||||
</button>
|
||||
)}
|
||||
{room.round < room.totalRounds && (
|
||||
<button className={btnGhost} onClick={onResetGame}>
|
||||
Arrêter la partie
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs sm:text-sm text-[#888] text-center animate-pulse-slow">
|
||||
En attente de l'hôte...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
// Entre les manches : attente que l'hôte démarre la suivante
|
||||
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="text-center flex flex-col items-center gap-4">
|
||||
<div className="text-2xl font-black">Manche {room.round}/{room.totalRounds} terminée</div>
|
||||
<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..."}
|
||||
</p>
|
||||
{isHost && (
|
||||
<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={btnGhost} onClick={onResetGame}>Arrêter la partie</button>
|
||||
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="text-center flex flex-col items-center gap-4">
|
||||
<div className="text-2xl font-black">
|
||||
Manche {room.round}/{room.totalRounds} terminée
|
||||
</div>
|
||||
)}
|
||||
<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..."}
|
||||
</p>
|
||||
{isHost && (
|
||||
<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={btnGhost} onClick={onResetGame}>
|
||||
Arrêter la partie
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
// Countdown
|
||||
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="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 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-base sm:text-lg font-bold text-center">{room.startArticle}</span>
|
||||
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="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 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-base sm:text-lg font-bold text-center">
|
||||
{room.startArticle}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">
|
||||
Cible
|
||||
</span>
|
||||
<span className="text-lg sm:text-xl font-bold text-[#7c3aed] text-center">
|
||||
{room.targetArticle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-[10px] sm:text-[11px] text-[#888] uppercase tracking-wider">Cible</span>
|
||||
<span className="text-lg sm:text-xl font-bold text-[#7c3aed] text-center">{room.targetArticle}</span>
|
||||
<div className="text-[clamp(72px,20vw,140px)] font-black leading-none text-[#7c3aed] animate-count-pop">
|
||||
{countdown !== null && countdown > 0 ? countdown : "Partez !"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[clamp(72px,20vw,140px)] font-black leading-none text-[#7c3aed] animate-count-pop">
|
||||
{countdown !== null && countdown > 0 ? countdown : "Partez !"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
// Playing
|
||||
return (
|
||||
@@ -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="flex-1 min-w-0 flex flex-col gap-0.5 sm:gap-1">
|
||||
<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-xs font-bold text-[#7c3aed] truncate">{room.targetArticle}</span>
|
||||
<span className="text-[9px] sm:text-[10px] font-bold uppercase tracking-wider text-[#888] shrink-0">
|
||||
Cible
|
||||
</span>
|
||||
<span className="text-xs font-bold text-[#7c3aed] truncate">
|
||||
{room.targetArticle}
|
||||
</span>
|
||||
</div>
|
||||
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
|
||||
</div>
|
||||
@@ -181,10 +271,14 @@ export function GameScreen({
|
||||
</button>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
@@ -200,7 +294,10 @@ export function GameScreen({
|
||||
{loadError && (
|
||||
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,21 +305,39 @@ export function GameScreen({
|
||||
{!loading && !loadError && html && (
|
||||
<div className="article-container">
|
||||
<h1 className="article-title">{title}</h1>
|
||||
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
|
||||
<ArticleView
|
||||
html={html}
|
||||
onNavigate={onNavigate}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - desktop seulement */}
|
||||
<aside className="hidden md:flex w-36 lg:w-44 shrink-0 bg-[#1a1a1a] border-l border-[#2e2e2e] flex-col px-3 py-4 overflow-y-auto gap-3">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-[#888]">Joueurs</h4>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-[#888]">
|
||||
Joueurs
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
{sortedPlayers.map((p) => (
|
||||
<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>
|
||||
{p.hasWon && <span className="text-[10px] text-green-400 font-bold">✓ Trouvé</span>}
|
||||
{p.hasSurrendered && <span className="text-[10px] text-[#888] font-bold">Forfait</span>}
|
||||
{p.hasWon && (
|
||||
<span className="text-[10px] text-green-400 font-bold">
|
||||
✓ Trouvé
|
||||
</span>
|
||||
)}
|
||||
{p.hasSurrendered && (
|
||||
<span className="text-[10px] text-[#888] font-bold">
|
||||
Forfait
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -35,18 +35,34 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) {
|
||||
useEffect(() => {
|
||||
fetch("/api/leaderboard")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setRows(data); setLoading(false); });
|
||||
.then((data) => {
|
||||
setRows(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (viewingUserId) {
|
||||
return <PublicProfileScreen userId={viewingUserId} onBack={() => setViewingUserId(null)} />;
|
||||
return (
|
||||
<PublicProfileScreen
|
||||
userId={viewingUserId}
|
||||
onBack={() => setViewingUserId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (mode === "solo") return b.soloWins - a.soloWins || b.soloGames - a.soloGames;
|
||||
if (mode === "multi") return b.multiWins - a.multiWins || b.multiGames - a.multiGames;
|
||||
if (mode === "solo")
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -77,28 +93,64 @@ export function LeaderboardScreen({ onBack }: { onBack: () => void }) {
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<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">
|
||||
{sorted.map((row, i) => {
|
||||
const games = mode === "solo" ? row.soloGames : mode === "multi" ? row.multiGames : row.totalGames;
|
||||
const wins = mode === "solo" ? row.soloWins : mode === "multi" ? row.multiWins : row.wins;
|
||||
const games =
|
||||
mode === "solo"
|
||||
? row.soloGames
|
||||
: mode === "multi"
|
||||
? row.multiGames
|
||||
: row.totalGames;
|
||||
const wins =
|
||||
mode === "solo"
|
||||
? row.soloWins
|
||||
: mode === "multi"
|
||||
? row.multiWins
|
||||
: row.wins;
|
||||
|
||||
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]"}`}>
|
||||
<span className="text-xl w-8 text-center shrink-0">{MEDALS[i] ?? `#${i + 1}`}</span>
|
||||
<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]"}`}
|
||||
>
|
||||
<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">
|
||||
<span className="font-bold text-sm truncate">{row.name}</span>
|
||||
<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="font-bold text-[#f0f0f0]">{games}</span> parties</span>
|
||||
<span className="text-xs text-[#888]">
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
@@ -35,14 +35,24 @@ function computeStats(games: Game[]): Stats {
|
||||
return {
|
||||
total: games.length,
|
||||
won: won.length,
|
||||
avgClicks: won.length ? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length) : 0,
|
||||
avgTime: won.length ? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length : 0,
|
||||
avgClicks: won.length
|
||||
? Math.round(won.reduce((s, g) => s + g.clicks, 0) / won.length)
|
||||
: 0,
|
||||
avgTime: won.length
|
||||
? won.reduce((s, g) => s + g.timeSeconds, 0) / won.length
|
||||
: 0,
|
||||
bestClicks: won.length ? Math.min(...won.map((g) => g.clicks)) : 0,
|
||||
bestTime: won.length ? Math.min(...won.map((g) => g.timeSeconds)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function ProfileScreen({ userName, onBack }: { userName: string; onBack: () => void }) {
|
||||
export function ProfileScreen({
|
||||
userName,
|
||||
onBack,
|
||||
}: {
|
||||
userName: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "solo" | "multi">("all");
|
||||
@@ -63,22 +73,29 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
.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 statCard = "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";
|
||||
const statCard =
|
||||
"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 (
|
||||
<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 */}
|
||||
<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">
|
||||
<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()}
|
||||
</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>
|
||||
<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"
|
||||
@@ -91,28 +108,50 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 my-3 sm:my-4">
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{stats.total}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Parties</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{stats.total}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Parties
|
||||
</span>
|
||||
</div>
|
||||
<div className={statCard}>
|
||||
<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 className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{stats.avgClicks > 0 ? stats.avgClicks : "-"}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Clics moy.</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{stats.avgClicks > 0 ? stats.avgClicks : "-"}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Clics moy.
|
||||
</span>
|
||||
</div>
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{stats.avgTime > 0 ? fmt(stats.avgTime) : "-"}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Temps moy.</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{stats.avgTime > 0 ? fmt(stats.avgTime) : "-"}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Temps moy.
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${statCard} border-[#7c3aed]`}>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestClicks > 0 ? stats.bestClicks : "-"}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur clics</span>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
|
||||
{stats.bestClicks > 0 ? stats.bestClicks : "-"}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Meilleur clics
|
||||
</span>
|
||||
</div>
|
||||
<div className={`${statCard} border-[#7c3aed]`}>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">{stats.bestTime > 0 ? fmt(stats.bestTime) : "-"}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Meilleur temps</span>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
|
||||
{stats.bestTime > 0 ? fmt(stats.bestTime) : "-"}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Meilleur temps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,22 +171,39 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
{/* Game list */}
|
||||
<div className="flex flex-col gap-2 sm:gap-2.5">
|
||||
{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 && (
|
||||
<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) => (
|
||||
<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">
|
||||
<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="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>
|
||||
<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="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 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="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>
|
||||
{g.won && (
|
||||
<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) => (
|
||||
<span key={i} className="flex items-center gap-0.5">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -173,15 +237,23 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
{/* 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="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 text-[#888]">Télécharge toutes tes données personnelles (RGPD).</p>
|
||||
<p className="text-xs font-bold text-[#f0f0f0] uppercase tracking-wider">
|
||||
Mes données
|
||||
</p>
|
||||
<p className="text-xs text-[#888]">
|
||||
Télécharge toutes tes données personnelles (RGPD).
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/account"
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
@@ -192,10 +264,14 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
|
||||
{/* Danger zone */}
|
||||
<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 ? (
|
||||
<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
|
||||
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)}
|
||||
@@ -206,7 +282,8 @@ export function ProfileScreen({ userName, onBack }: { userName: string; onBack:
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -48,20 +48,29 @@ export function PublicProfileScreen({
|
||||
useEffect(() => {
|
||||
fetch(`/api/users/${userId}`)
|
||||
.then((r) => {
|
||||
if (r.status === 404) { setNotFound(true); return null; }
|
||||
if (r.status === 404) {
|
||||
setNotFound(true);
|
||||
return null;
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((d) => { if (d) setProfile(d); })
|
||||
.then((d) => {
|
||||
if (d) setProfile(d);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [userId]);
|
||||
|
||||
const statCard = "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";
|
||||
const statCard =
|
||||
"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 (
|
||||
<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">
|
||||
<button className={btnGhost} onClick={onBack}>← Retour</button>
|
||||
<button className={btnGhost} onClick={onBack}>
|
||||
← Retour
|
||||
</button>
|
||||
{profile && (
|
||||
<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">
|
||||
@@ -70,64 +79,109 @@ export function PublicProfileScreen({
|
||||
<div>
|
||||
<h2 className="text-base sm:text-xl font-bold">{profile.name}</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <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>}
|
||||
{loading && (
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Stats principales */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-2.5">
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{profile.stats.total}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Parties</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{profile.stats.total}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Parties
|
||||
</span>
|
||||
</div>
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{profile.stats.wins}</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Victoires</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{profile.stats.wins}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Victoires
|
||||
</span>
|
||||
</div>
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">{profile.stats.winRate}%</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">Win rate</span>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{profile.stats.winRate}%
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] uppercase tracking-wider text-[#888]">
|
||||
Win rate
|
||||
</span>
|
||||
</div>
|
||||
<div className={statCard}>
|
||||
<span className="text-lg sm:text-[22px] font-black">
|
||||
{profile.stats.avgClicks ?? "-"}
|
||||
</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 className={`${statCard} border-[#7c3aed]`}>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
|
||||
{profile.stats.bestClicks ?? "-"}
|
||||
</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 className={`${statCard} border-[#7c3aed]`}>
|
||||
<span className="text-lg sm:text-[22px] font-black text-[#7c3aed]">
|
||||
{profile.stats.bestTime ? fmt(profile.stats.bestTime) : "-"}
|
||||
</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>
|
||||
|
||||
{/* Stats par mode */}
|
||||
<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) => {
|
||||
const s = profile.stats[m];
|
||||
if (s.games === 0) return null;
|
||||
return (
|
||||
<div 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
|
||||
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">
|
||||
<span><span className="text-[#f0f0f0] font-bold">{s.wins}</span> victoires</span>
|
||||
<span><span className="text-[#f0f0f0] font-bold">{s.games}</span> parties</span>
|
||||
<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>
|
||||
);
|
||||
|
||||
+135
-84
@@ -42,95 +42,146 @@ type Props = {
|
||||
};
|
||||
|
||||
export function ScreenRouter({
|
||||
screen, setScreen, session, solo, multi, handlers,
|
||||
playerName, setPlayerName, joinCode, setJoinCode,
|
||||
maxPlayers, setMaxPlayers,
|
||||
totalRounds, setTotalRounds,
|
||||
gameMode, setGameMode,
|
||||
error, setError, loading, showAuth, setShowAuth,
|
||||
screen,
|
||||
setScreen,
|
||||
session,
|
||||
solo,
|
||||
multi,
|
||||
handlers,
|
||||
playerName,
|
||||
setPlayerName,
|
||||
joinCode,
|
||||
setJoinCode,
|
||||
maxPlayers,
|
||||
setMaxPlayers,
|
||||
totalRounds,
|
||||
setTotalRounds,
|
||||
gameMode,
|
||||
setGameMode,
|
||||
error,
|
||||
setError,
|
||||
loading,
|
||||
showAuth,
|
||||
setShowAuth,
|
||||
}: Props) {
|
||||
if (screen === "profile") return (
|
||||
<ProfileScreen userName={session?.user?.name ?? "Joueur"} onBack={() => setScreen("home")} />
|
||||
);
|
||||
|
||||
if (screen === "leaderboard") return (
|
||||
<LeaderboardScreen onBack={() => setScreen("home")} />
|
||||
);
|
||||
|
||||
if (screen === "daily") return (
|
||||
<DailyScreen onBack={() => setScreen("home")} currentUserId={session?.user?.id ?? undefined} />
|
||||
);
|
||||
|
||||
if (screen === "blitz") return (
|
||||
<BlitzScreen onBack={() => setScreen("home")} />
|
||||
);
|
||||
|
||||
if (screen === "home") return (
|
||||
<>
|
||||
<HomeScreen
|
||||
playerName={playerName} setPlayerName={setPlayerName}
|
||||
joinCode={joinCode} setJoinCode={setJoinCode}
|
||||
error={error} setError={setError} loading={loading}
|
||||
onCreateRoom={handlers.handleCreateRoom}
|
||||
onJoinRoom={handlers.handleJoinRoom}
|
||||
onSolo={handlers.handleSolo}
|
||||
session={session}
|
||||
onShowAuth={() => setShowAuth(true)}
|
||||
onShowProfile={() => setScreen("profile")}
|
||||
onShowLeaderboard={() => setScreen("leaderboard")}
|
||||
onDaily={() => setScreen("daily")}
|
||||
onBlitz={() => setScreen("blitz")}
|
||||
if (screen === "profile")
|
||||
return (
|
||||
<ProfileScreen
|
||||
userName={session?.user?.name ?? "Joueur"}
|
||||
onBack={() => setScreen("home")}
|
||||
/>
|
||||
{showAuth && <AuthModal onClose={() => setShowAuth(false)} onSuccess={() => setShowAuth(false)} />}
|
||||
</>
|
||||
);
|
||||
);
|
||||
|
||||
if (screen === "solo") return (
|
||||
<SoloScreen
|
||||
phase={solo.phase} puzzle={solo.puzzle}
|
||||
html={solo.html} title={solo.title}
|
||||
loading={solo.loading} loadError={solo.loadError}
|
||||
history={solo.history} clicks={solo.clicks}
|
||||
elapsedDisplay={fmt(solo.elapsed)}
|
||||
canGoBack={solo.canGoBack}
|
||||
onStart={solo.start}
|
||||
onNavigate={solo.navigate}
|
||||
onBack={solo.goBack}
|
||||
onQuit={() => { solo.reset(); setScreen("home"); }}
|
||||
onNewGame={solo.start}
|
||||
onRetry={solo.retryLoad}
|
||||
/>
|
||||
);
|
||||
if (screen === "leaderboard")
|
||||
return <LeaderboardScreen onBack={() => setScreen("home")} />;
|
||||
|
||||
if (screen === "lobby" && multi.room && multi.playerId) return (
|
||||
<LobbyScreen
|
||||
room={multi.room} playerId={multi.playerId}
|
||||
error={error} setError={setError} loading={loading}
|
||||
onLeave={handlers.handleLeave}
|
||||
onStart={handlers.handleStartGame}
|
||||
onReset={handlers.handleResetGame}
|
||||
maxPlayers={maxPlayers} setMaxPlayers={setMaxPlayers}
|
||||
totalRounds={totalRounds} setTotalRounds={setTotalRounds}
|
||||
gameMode={gameMode} setGameMode={setGameMode}
|
||||
onSetSearchAllowed={(v) => multi.setSearchAllowed(v)}
|
||||
/>
|
||||
);
|
||||
if (screen === "daily")
|
||||
return (
|
||||
<DailyScreen
|
||||
onBack={() => setScreen("home")}
|
||||
currentUserId={session?.user?.id ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (screen === "game" && multi.room && multi.playerId) return (
|
||||
<GameScreen
|
||||
room={multi.room} playerId={multi.playerId}
|
||||
html={multi.html} title={multi.title}
|
||||
loading={multi.loading} loadError={multi.loadError}
|
||||
history={multi.history} clicks={multi.clicks}
|
||||
elapsed={fmt(multi.elapsed)}
|
||||
countdown={multi.countdown}
|
||||
onNavigate={multi.navigate}
|
||||
onRetry={multi.retryLoad}
|
||||
onNextRound={handlers.handleNextRound}
|
||||
onResetGame={handlers.handleResetGame}
|
||||
onSurrender={handlers.handleSurrender}
|
||||
/>
|
||||
);
|
||||
if (screen === "blitz")
|
||||
return <BlitzScreen onBack={() => setScreen("home")} />;
|
||||
|
||||
if (screen === "home")
|
||||
return (
|
||||
<>
|
||||
<HomeScreen
|
||||
playerName={playerName}
|
||||
setPlayerName={setPlayerName}
|
||||
joinCode={joinCode}
|
||||
setJoinCode={setJoinCode}
|
||||
error={error}
|
||||
setError={setError}
|
||||
loading={loading}
|
||||
onCreateRoom={handlers.handleCreateRoom}
|
||||
onJoinRoom={handlers.handleJoinRoom}
|
||||
onSolo={handlers.handleSolo}
|
||||
session={session}
|
||||
onShowAuth={() => setShowAuth(true)}
|
||||
onShowProfile={() => setScreen("profile")}
|
||||
onShowLeaderboard={() => setScreen("leaderboard")}
|
||||
onDaily={() => setScreen("daily")}
|
||||
onBlitz={() => setScreen("blitz")}
|
||||
/>
|
||||
{showAuth && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuth(false)}
|
||||
onSuccess={() => setShowAuth(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (screen === "solo")
|
||||
return (
|
||||
<SoloScreen
|
||||
phase={solo.phase}
|
||||
puzzle={solo.puzzle}
|
||||
html={solo.html}
|
||||
title={solo.title}
|
||||
loading={solo.loading}
|
||||
loadError={solo.loadError}
|
||||
history={solo.history}
|
||||
clicks={solo.clicks}
|
||||
elapsedDisplay={fmt(solo.elapsed)}
|
||||
canGoBack={solo.canGoBack}
|
||||
onStart={solo.start}
|
||||
onNavigate={solo.navigate}
|
||||
onBack={solo.goBack}
|
||||
onQuit={() => {
|
||||
solo.reset();
|
||||
setScreen("home");
|
||||
}}
|
||||
onNewGame={solo.start}
|
||||
onRetry={solo.retryLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
if (screen === "lobby" && multi.room && multi.playerId)
|
||||
return (
|
||||
<LobbyScreen
|
||||
room={multi.room}
|
||||
playerId={multi.playerId}
|
||||
error={error}
|
||||
setError={setError}
|
||||
loading={loading}
|
||||
onLeave={handlers.handleLeave}
|
||||
onStart={handlers.handleStartGame}
|
||||
onReset={handlers.handleResetGame}
|
||||
maxPlayers={maxPlayers}
|
||||
setMaxPlayers={setMaxPlayers}
|
||||
totalRounds={totalRounds}
|
||||
setTotalRounds={setTotalRounds}
|
||||
gameMode={gameMode}
|
||||
setGameMode={setGameMode}
|
||||
onSetSearchAllowed={(v) => multi.setSearchAllowed(v)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (screen === "game" && multi.room && multi.playerId)
|
||||
return (
|
||||
<GameScreen
|
||||
room={multi.room}
|
||||
playerId={multi.playerId}
|
||||
html={multi.html}
|
||||
title={multi.title}
|
||||
loading={multi.loading}
|
||||
loadError={multi.loadError}
|
||||
history={multi.history}
|
||||
clicks={multi.clicks}
|
||||
elapsed={fmt(multi.elapsed)}
|
||||
countdown={multi.countdown}
|
||||
onNavigate={multi.navigate}
|
||||
onRetry={multi.retryLoad}
|
||||
onNextRound={handlers.handleNextRound}
|
||||
onResetGame={handlers.handleResetGame}
|
||||
onSurrender={handlers.handleSurrender}
|
||||
/>
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+137
-66
@@ -29,75 +29,120 @@ type SoloScreenProps = {
|
||||
};
|
||||
|
||||
export function SoloScreen({
|
||||
phase, puzzle, html, title, loading, loadError, history, clicks,
|
||||
elapsedDisplay, canGoBack, onStart, onNavigate, onBack, onQuit, onNewGame, onRetry,
|
||||
phase,
|
||||
puzzle,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
history,
|
||||
clicks,
|
||||
elapsedDisplay,
|
||||
canGoBack,
|
||||
onStart,
|
||||
onNavigate,
|
||||
onBack,
|
||||
onQuit,
|
||||
onNewGame,
|
||||
onRetry,
|
||||
}: SoloScreenProps) {
|
||||
const breadcrumbEndRef = useRef<HTMLDivElement>(null);
|
||||
const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
breadcrumbEndRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "end" });
|
||||
breadcrumbEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "end",
|
||||
});
|
||||
}, [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 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 btnPrimary =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors";
|
||||
const btnGhost =
|
||||
"w-full min-h-11 rounded-xl text-sm font-semibold bg-[#242424] border border-[#2e2e2e] text-[#f0f0f0] hover:bg-[#1a1a1a] cursor-pointer transition-colors";
|
||||
|
||||
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">
|
||||
<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
|
||||
</button>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Mode Solo</h2>
|
||||
<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 !
|
||||
</p>
|
||||
<button
|
||||
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]"}`}
|
||||
>
|
||||
<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]"}`}>
|
||||
{searchAllowed ? "Autorisée" : "Bloquée"}
|
||||
</span>
|
||||
</button>
|
||||
<button className={btnPrimary} onClick={onStart} disabled={loading}>
|
||||
{loading ? "Préparation..." : "Lancer une partie"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
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">
|
||||
<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
|
||||
</button>
|
||||
<h2 className="text-2xl sm:text-3xl font-black">Mode Solo</h2>
|
||||
<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 !
|
||||
</p>
|
||||
<button
|
||||
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]"}`}
|
||||
>
|
||||
<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]"}`}
|
||||
>
|
||||
{searchAllowed ? "Autorisée" : "Bloquée"}
|
||||
</span>
|
||||
</button>
|
||||
<button className={btnPrimary} onClick={onStart} disabled={loading}>
|
||||
{loading ? "Préparation..." : "Lancer une partie"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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="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>
|
||||
<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 flex-col gap-1">
|
||||
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">{clicks}</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
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="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>
|
||||
<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 flex-col gap-1">
|
||||
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">
|
||||
{clicks}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">clics</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">
|
||||
{elapsedDisplay}
|
||||
</span>
|
||||
<span className="text-xs text-[#888]">temps</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-3xl sm:text-4xl font-black text-[#7c3aed]">{elapsedDisplay}</span>
|
||||
<span className="text-xs text-[#888]">temps</span>
|
||||
<div className="bg-[#1a1a1a] rounded-xl px-3 sm:px-4 py-3 flex flex-wrap gap-1 text-xs sm:text-sm text-[#888] text-left">
|
||||
{history.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span
|
||||
className={
|
||||
i === history.length - 1
|
||||
? "text-[#16a34a] font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ShareBar
|
||||
text={`🎉 J'ai atteint "${puzzle?.target}" en ${clicks} clics et ${elapsedDisplay} sur WikiRush !`}
|
||||
/>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<button className={btnPrimary} onClick={onNewGame}>
|
||||
Nouvelle partie
|
||||
</button>
|
||||
<button className={btnGhost} onClick={onQuit}>
|
||||
Accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1a1a] rounded-xl px-3 sm:px-4 py-3 flex flex-wrap gap-1 text-xs sm:text-sm text-[#888] text-left">
|
||||
{history.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#555]">›</span>}
|
||||
<span className={i === history.length - 1 ? "text-[#16a34a] font-semibold" : ""}>{t}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ShareBar text={`🎉 J'ai atteint "${puzzle?.target}" en ${clicks} clics et ${elapsedDisplay} sur WikiRush !`} />
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<button className={btnPrimary} onClick={onNewGame}>Nouvelle partie</button>
|
||||
<button className={btnGhost} onClick={onQuit}>Accueil</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
// Playing
|
||||
return (
|
||||
@@ -113,13 +158,20 @@ export function SoloScreen({
|
||||
{loadError && (
|
||||
<div className="flex flex-col items-center gap-4 py-10 px-4 text-center">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !loadError && html && (
|
||||
<ArticleView html={html} onNavigate={onNavigate} disabled={loading} />
|
||||
<ArticleView
|
||||
html={html}
|
||||
onNavigate={onNavigate}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,25 +179,44 @@ export function SoloScreen({
|
||||
{/* 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="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-sm sm:text-base font-black text-[#7c3aed] truncate">{puzzle?.target}</span>
|
||||
<span className="text-[8px] sm:text-[9px] font-bold tracking-widest text-[#888] uppercase">
|
||||
Trouver
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-black text-[#7c3aed] truncate">
|
||||
{puzzle?.target}
|
||||
</span>
|
||||
<Breadcrumbs history={history} endRef={breadcrumbEndRef} />
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">Temps</span>
|
||||
<span className="text-xs sm:text-sm font-black tabular-nums">{elapsedDisplay}</span>
|
||||
<span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">
|
||||
Temps
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm font-black tabular-nums">
|
||||
{elapsedDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<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-xs sm:text-sm font-black tabular-nums">{clicks}</span>
|
||||
<span className="text-[8px] sm:text-[9px] font-bold tracking-wider text-[#888] uppercase">
|
||||
Clics
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm font-black tabular-nums">
|
||||
{clicks}
|
||||
</span>
|
||||
</div>
|
||||
{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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,8 +64,8 @@ export default function MentionsLegalesPage() {
|
||||
SAPINET
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[#888]">SIREN :</span> 899 483 457 (RCS
|
||||
de Nanterre)
|
||||
<span className="text-[#888]">SIREN :</span> 899 483 457 (RCS de
|
||||
Nanterre)
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[#888]">Adresse :</span> 65 rue de la
|
||||
|
||||
+40
-11
@@ -22,20 +22,49 @@ export default function WikiRush() {
|
||||
const solo = useSoloGame();
|
||||
const multi = useMultiGame();
|
||||
|
||||
const { session } = useGameEffects({ solo, multi, screen, setScreen, setPlayerName });
|
||||
const handlers = useGameHandlers({ solo, multi, playerName, joinCode, maxPlayers, totalRounds, gameMode, setScreen, setError, setLoading });
|
||||
const { session } = useGameEffects({
|
||||
solo,
|
||||
multi,
|
||||
screen,
|
||||
setScreen,
|
||||
setPlayerName,
|
||||
});
|
||||
const handlers = useGameHandlers({
|
||||
solo,
|
||||
multi,
|
||||
playerName,
|
||||
joinCode,
|
||||
maxPlayers,
|
||||
totalRounds,
|
||||
gameMode,
|
||||
setScreen,
|
||||
setError,
|
||||
setLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenRouter
|
||||
screen={screen} setScreen={setScreen} session={session}
|
||||
solo={solo} multi={multi} handlers={handlers}
|
||||
playerName={playerName} setPlayerName={setPlayerName}
|
||||
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}
|
||||
screen={screen}
|
||||
setScreen={setScreen}
|
||||
session={session}
|
||||
solo={solo}
|
||||
multi={multi}
|
||||
handlers={handlers}
|
||||
playerName={playerName}
|
||||
setPlayerName={setPlayerName}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user