feat(admin): add admin panel with user management, daily puzzle CRUD, and activity graph

- Add admin page at /admin (restricted to ADMIN_EMAIL via proxy)
- Implement user ban/unban and deletion
- Add daily puzzle creation, modification, deletion
- Include 14-day activity chart with hover details
- Add User.banned field to schema and migrate database
- Block banned users from logging in
- Add ADMIN_EMAIL to .env.local configuration
- Update Prisma client after schema changes
This commit is contained in:
jessy-david-dev
2026-04-11 15:10:08 +02:00
parent 5d8f7eae45
commit 8ff64b3470
22 changed files with 899 additions and 122 deletions
+419
View File
@@ -0,0 +1,419 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type User = {
id: string;
name: string;
email: string;
banned: boolean;
createdAt: string;
_count: { games: number };
};
type Game = {
id: string;
mode: string;
startArticle: string;
targetArticle: string;
clicks: number;
timeSeconds: number;
won: boolean;
playedAt: string;
user: { name: string };
};
type ModeStat = {
mode: string;
_count: { id: number };
_avg: { clicks: number | null; timeSeconds: number | null };
};
type ActivityDay = { date: string; count: number };
type AdminStats = {
totalUsers: number;
totalGames: number;
todayGames: number;
recentUsers: User[];
recentGames: Game[];
modeStats: ModeStat[];
activity: ActivityDay[];
};
type DailyPuzzle = {
id: string;
date: string;
startArticle: string;
targetArticle: string;
_count: { results: number };
};
function fmt(s: number) {
const m = Math.floor(s / 60);
const sec = Math.round(s % 60);
return m > 0 ? `${m}m${sec.toString().padStart(2, "0")}s` : `${sec}s`;
}
function MiniBarChart({ data }: { data: ActivityDay[] }) {
const max = Math.max(...data.map((d) => d.count), 1);
return (
<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" });
return (
<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}
</div>
</div>
);
})}
</div>
);
}
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");
// Daily puzzle form
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);
function refreshStats() {
return fetch("/api/admin/stats")
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then(setData);
}
function refreshDaily() {
return fetch("/api/admin/daily")
.then((r) => r.json())
.then(setDailyPuzzles);
}
useEffect(() => {
Promise.all([refreshStats(), refreshDaily()])
.catch(() => setData(null))
.finally(() => setLoading(false));
}, []);
async function handleBan(user: User) {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ banned: !user.banned }),
});
if (res.ok) await refreshStats();
}
async function handleDeleteUser(id: string) {
await fetch(`/api/admin/users/${id}`, { method: "DELETE" });
await refreshStats();
setConfirmDelete(null);
}
async function handleDeletePuzzle(id: string) {
await fetch(`/api/admin/daily/${id}`, { method: "DELETE" });
await refreshDaily();
setConfirmDelete(null);
}
async function handleSavePuzzle() {
setPuzzleSaving(true);
try {
if (editingPuzzle) {
await fetch(`/api/admin/daily/${editingPuzzle.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startArticle: puzzleForm.startArticle, targetArticle: puzzleForm.targetArticle }),
});
} else {
await fetch("/api/admin/daily", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(puzzleForm),
});
}
await refreshDaily();
setPuzzleForm({ date: "", startArticle: "", targetArticle: "" });
setEditingPuzzle(null);
} finally {
setPuzzleSaving(false);
}
}
function startEditPuzzle(p: DailyPuzzle) {
setEditingPuzzle(p);
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]"
}`;
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">
{/* Header */}
<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&apos;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">
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>}
{/* 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>
<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-red-700 text-white cursor-pointer hover:bg-red-600"
onClick={() => confirmDelete.type === "user" ? handleDeleteUser(confirmDelete.id) : handleDeletePuzzle(confirmDelete.id)}
>
Supprimer
</button>
</div>
</div>
</div>
)}
{data && (
<>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
<button className={tabBtn(tab === "overview")} onClick={() => setTab("overview")}>Vue d&apos;ensemble</button>
<button className={tabBtn(tab === "users")} onClick={() => setTab("users")}>
Utilisateurs <span className="ml-1 text-[#555]">({data.totalUsers})</span>
</button>
<button className={tabBtn(tab === "games")} onClick={() => setTab("games")}>Parties récentes</button>
<button className={tabBtn(tab === "daily")} onClick={() => setTab("daily")}>Défis du jour</button>
</div>
{/* Overview */}
{tab === "overview" && (
<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") },
].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>
</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>
<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&apos;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>
<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>}
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Users */}
{tab === "users" && (
<div className={card}>
<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 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>}
</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>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleBan(u)}
className={`min-h-8 px-2.5 rounded-lg text-xs font-semibold cursor-pointer transition-colors ${u.banned ? "bg-green-900/30 text-green-400 hover:bg-green-900/50" : "bg-orange-900/30 text-orange-400 hover:bg-orange-900/50"}`}
>
{u.banned ? "Débannir" : "Bannir"}
</button>
<button
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
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Games */}
{tab === "games" && (
<div className={card}>
<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 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>
</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"}`}>
{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>
</div>
</div>
))}
</div>
</div>
)}
{/* Daily puzzles */}
{tab === "daily" && (
<div className="flex flex-col gap-4">
{/* 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"}
</h2>
<div className="flex flex-col gap-2.5">
{!editingPuzzle && (
<input
className={inputCls}
type="date"
value={puzzleForm.date}
onChange={(e) => setPuzzleForm((f) => ({ ...f, date: e.target.value }))}
placeholder="Date (YYYY-MM-DD)"
/>
)}
<input
className={inputCls}
type="text"
value={puzzleForm.startArticle}
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 }))}
placeholder="Article cible (ex: Napoléon Bonaparte)"
/>
<div className="flex gap-2">
<button
onClick={handleSavePuzzle}
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"}
</button>
{editingPuzzle && (
<button
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
</button>
)}
</div>
</div>
</div>
{/* Liste */}
{dailyPuzzles && (
<div className={card}>
<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 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>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => startEditPuzzle(p)}
className="min-h-8 px-2.5 rounded-lg text-xs font-semibold bg-[#242424] border border-[#2e2e2e] text-[#888] hover:text-[#f0f0f0] cursor-pointer"
>
Modifier
</button>
<button
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
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
type Params = { params: Promise<{ id: string }> };
// PATCH /api/admin/daily/[id] — modifier un puzzle
export async function PATCH(req: Request, { params }: Params) {
const { id } = await params;
const { startArticle, targetArticle } = await req.json() as {
startArticle: string;
targetArticle: string;
};
const puzzle = await prisma.dailyPuzzle.update({
where: { id },
data: { startArticle, targetArticle },
});
return NextResponse.json(puzzle);
}
// DELETE /api/admin/daily/[id] — supprimer un puzzle
export async function DELETE(_req: Request, { params }: Params) {
const { id } = await params;
await prisma.dailyPuzzle.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
// GET /api/admin/daily — liste tous les puzzles
export async function GET() {
const puzzles = await prisma.dailyPuzzle.findMany({
orderBy: { date: "desc" },
take: 30,
include: { _count: { select: { results: true } } },
});
return NextResponse.json(puzzles);
}
// POST /api/admin/daily — créer un puzzle pour une date
export async function POST(req: Request) {
const { date, startArticle, targetArticle } = await req.json() as {
date: string;
startArticle: string;
targetArticle: string;
};
const puzzle = await prisma.dailyPuzzle.upsert({
where: { date },
create: { date, startArticle, targetArticle },
update: { startArticle, targetArticle },
});
return NextResponse.json(puzzle);
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function GET() {
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
// Activité sur les 14 derniers jours
const since14 = new Date(todayStart);
since14.setDate(since14.getDate() - 13);
const [totalUsers, totalGames, todayGames, recentUsers, recentGames, modeStats, activityRaw] =
await Promise.all([
prisma.user.count(),
prisma.game.count(),
prisma.game.count({ where: { playedAt: { gte: todayStart } } }),
prisma.user.findMany({
orderBy: { createdAt: "desc" },
take: 50,
select: {
id: true,
name: true,
email: true,
banned: true,
createdAt: true,
_count: { select: { games: true } },
},
}),
prisma.game.findMany({
orderBy: { playedAt: "desc" },
take: 50,
select: {
id: true,
mode: true,
startArticle: true,
targetArticle: true,
clicks: true,
timeSeconds: true,
won: true,
playedAt: true,
user: { select: { name: true } },
},
}),
prisma.game.groupBy({
by: ["mode"],
_count: { id: true },
_avg: { clicks: true, timeSeconds: true },
}),
prisma.game.findMany({
where: { playedAt: { gte: since14 } },
select: { playedAt: true },
}),
]);
// Agréger l'activité par jour
const activityMap = new Map<string, number>();
for (let i = 0; i < 14; i++) {
const d = new Date(since14);
d.setDate(d.getDate() + i);
activityMap.set(d.toISOString().slice(0, 10), 0);
}
for (const g of activityRaw) {
const key = g.playedAt.toISOString().slice(0, 10);
activityMap.set(key, (activityMap.get(key) ?? 0) + 1);
}
const activity = Array.from(activityMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
return NextResponse.json({
totalUsers,
totalGames,
todayGames,
recentUsers,
recentGames,
modeStats,
activity,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
type Params = { params: Promise<{ id: string }> };
// DELETE /api/admin/users/[id] — supprimer un utilisateur
export async function DELETE(_req: Request, { params }: Params) {
const { id } = await params;
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
// PATCH /api/admin/users/[id] — ban/unban
export async function PATCH(req: Request, { params }: Params) {
const { id } = await params;
const { banned } = await req.json() as { banned: boolean };
const user = await prisma.user.update({ where: { id }, data: { banned } });
return NextResponse.json({ id: user.id, banned: user.banned });
}
+7 -49
View File
@@ -30,10 +30,6 @@ type HomeScreenProps = {
setPlayerName: (v: string) => void;
joinCode: string;
setJoinCode: (v: string) => void;
maxPlayers: number;
setMaxPlayers: (v: number) => void;
totalRounds: number;
setTotalRounds: (v: number) => void;
error: string | null;
setError: (v: string | null) => void;
loading: boolean;
@@ -53,10 +49,6 @@ export function HomeScreen({
setPlayerName,
joinCode,
setJoinCode,
maxPlayers,
setMaxPlayers,
totalRounds,
setTotalRounds,
error,
setError,
loading,
@@ -155,47 +147,13 @@ export function HomeScreen({
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">
Multijoueur
</h3>
<div className="flex items-center justify-between gap-3">
<button
className="flex-1 min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors"
onClick={onCreateRoom}
disabled={loading}
>
{loading ? "Création..." : "Créer une partie"}
</button>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-[#888] whitespace-nowrap">
Max
</span>
<select
value={maxPlayers}
onChange={(e) => setMaxPlayers(Number(e.target.value))}
className="min-h-11 px-2 bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors"
>
{[2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16].map((n) => (
<option key={n} value={n}>
{n} joueurs
</option>
))}
</select>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-[#888] whitespace-nowrap">
Manches
</span>
<select
value={totalRounds}
onChange={(e) => setTotalRounds(Number(e.target.value))}
className="min-h-11 px-2 bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors"
>
{[1, 2, 3, 4, 5, 7, 10].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
</div>
<button
className="w-full min-h-11 rounded-xl text-sm font-semibold bg-[#7c3aed] text-white hover:bg-[#6d28d9] disabled:opacity-50 cursor-pointer transition-colors"
onClick={onCreateRoom}
disabled={loading}
>
{loading ? "Création..." : "Créer une partie"}
</button>
{/* Join row - colonne sur mobile, ligne sur sm+ */}
<div className="flex flex-col sm:flex-row gap-2">
<input
+132 -20
View File
@@ -13,13 +13,29 @@ type LobbyScreenProps = {
onLeave: () => void;
onStart: () => void;
onReset: () => void;
maxPlayers: number;
setMaxPlayers: (v: number) => void;
totalRounds: number;
setTotalRounds: (v: number) => void;
};
export function LobbyScreen({
room, playerId, error, setError, loading, onLeave, onStart, onReset,
room,
playerId,
error,
setError,
loading,
onLeave,
onStart,
onReset,
maxPlayers,
setMaxPlayers,
totalRounds,
setTotalRounds,
}: LobbyScreenProps) {
const isHost = room.players.find((p) => p.id === playerId)?.isHost ?? false;
const [copied, setCopied] = useState(false);
const [blurred, setBlurred] = useState(true);
const { allowed: searchAllowed, toggle: toggleSearch } = useSearchAllowed();
function copyCode() {
@@ -29,33 +45,62 @@ export function LobbyScreen({
});
}
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] disabled:opacity-50 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] disabled:opacity-50 cursor-pointer transition-colors";
return (
<div className="min-h-dvh w-full bg-[#0f0f0f] text-[#f0f0f0] animate-fade-in flex flex-col items-center px-4 py-5 gap-5 sm:gap-6 max-w-120 mx-auto">
<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={onLeave}>
<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={onLeave}
>
Quitter
</button>
{/* Code */}
<div className="w-full text-center bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-5 sm:p-6">
<span className="block text-[10px] sm:text-xs text-[#888] uppercase tracking-widest mb-2">Code de la salle</span>
<button className="flex items-center justify-center gap-2 mx-auto px-2 py-1 rounded-lg hover:bg-[#242424] cursor-pointer bg-transparent border-none" onClick={copyCode}>
<span className="text-5xl sm:text-[clamp(42px,12vw,64px)] font-black font-mono tracking-[0.15em] text-[#7c3aed] leading-none">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] sm:text-xs text-[#888] uppercase tracking-widest">
Code de la salle
</span>
<button
onClick={() => setBlurred((b) => !b)}
className="text-[10px] sm:text-xs text-[#555] hover:text-[#888] transition-colors cursor-pointer"
>
{blurred ? "Afficher" : "Masquer"}
</button>
</div>
<button
className="flex items-center justify-center gap-2 mx-auto px-2 py-1 rounded-lg hover:bg-[#242424] cursor-pointer bg-transparent border-none"
onClick={copyCode}
>
<span
className={`text-5xl sm:text-[clamp(42px,12vw,64px)] font-black font-mono tracking-[0.15em] text-[#7c3aed] leading-none transition-all duration-200 select-none ${blurred ? "blur-md" : ""}`}
>
{room.code}
</span>
<span className={`text-lg sm:text-xl leading-none transition-colors ${copied ? "text-green-400" : "text-[#888]"}`}>
<span
className={`text-lg sm:text-xl leading-none transition-colors ${copied ? "text-green-400" : "text-[#888]"}`}
>
{copied ? "✓" : "⧉"}
</span>
</button>
<span className="block text-xs text-[#888] mt-2">{copied ? "Copié !" : "Clique pour copier"}</span>
<span className="block text-xs text-[#888] mt-2">
{copied ? "Copié !" : "Clique pour copier"}
</span>
</div>
{error && (
<div className="w-full flex items-center justify-between gap-3 bg-red-950/40 border border-red-600 text-red-300 px-3 py-2.5 rounded-xl text-xs sm:text-sm">
{error}
<button onClick={() => setError(null)} className="shrink-0 px-1.5 rounded hover:bg-white/10 cursor-pointer"></button>
<button
onClick={() => setError(null)}
className="shrink-0 px-1.5 rounded hover:bg-white/10 cursor-pointer"
>
</button>
</div>
)}
@@ -66,39 +111,106 @@ export function LobbyScreen({
</h3>
<ul className="flex flex-col gap-2">
{room.players.map((p) => (
<li key={p.id} className={`flex items-center gap-2 bg-[#1a1a1a] rounded-xl px-3.5 py-2.5 min-h-11 border ${p.id === playerId ? "border-[#7c3aed]" : "border-[#2e2e2e]"}`}>
<span className="flex-1 font-semibold text-sm truncate">{p.name}</span>
{p.isHost && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-purple-900/40 text-purple-300 uppercase tracking-wide shrink-0">Hôte</span>}
{p.id === playerId && <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-900/40 text-blue-300 uppercase tracking-wide shrink-0">Toi</span>}
<span className="text-sm font-bold text-[#7c3aed] shrink-0">{p.score} pts</span>
<li
key={p.id}
className={`flex items-center gap-2 bg-[#1a1a1a] rounded-xl px-3.5 py-2.5 min-h-11 border ${p.id === playerId ? "border-[#7c3aed]" : "border-[#2e2e2e]"}`}
>
<span className="flex-1 font-semibold text-sm truncate">
{p.name}
</span>
{p.isHost && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-purple-900/40 text-purple-300 uppercase tracking-wide shrink-0">
Hôte
</span>
)}
{p.id === playerId && (
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-900/40 text-blue-300 uppercase tracking-wide shrink-0">
Toi
</span>
)}
<span className="text-sm font-bold text-[#7c3aed] shrink-0">
{p.score} pts
</span>
</li>
))}
</ul>
</div>
{isHost && (
<div className="w-full bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-3">
<h3 className="text-[10px] sm:text-xs font-bold text-[#888] uppercase tracking-wider">
Paramètres
</h3>
<div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1.5">
<span className="text-xs text-[#888]">Joueurs max</span>
<select
value={maxPlayers}
onChange={(e) => setMaxPlayers(Number(e.target.value))}
className="w-full min-h-11 px-2 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors"
>
{[2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16].map((n) => (
<option key={n} value={n}>
{n} joueurs
</option>
))}
</select>
</div>
<div className="flex-1 flex flex-col gap-1.5">
<span className="text-xs text-[#888]">Manches</span>
<select
value={totalRounds}
onChange={(e) => setTotalRounds(Number(e.target.value))}
className="w-full min-h-11 px-2 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors"
>
{[1, 2, 3, 4, 5, 7, 10].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
</div>
</div>
)}
<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]"}`}>
<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>
{room.round > 0 && <p className="text-xs text-[#888] text-center">Manche {room.round} terminée</p>}
{room.round > 0 && (
<p className="text-xs text-[#888] text-center">
Manche {room.round} terminée
</p>
)}
{isHost ? (
<div className="w-full flex flex-col gap-2.5">
<button className={btnPrimary} onClick={onStart} disabled={loading}>
{loading ? "Préparation..." : room.round === 0 ? "Démarrer la partie" : "Manche suivante"}
{loading
? "Préparation..."
: room.round === 0
? "Démarrer la partie"
: "Manche suivante"}
</button>
{room.round > 0 && (
<button className={btnGhost} onClick={onReset}>Nouvelle partie (reset scores)</button>
<button className={btnGhost} onClick={onReset}>
Nouvelle partie (reset scores)
</button>
)}
</div>
) : (
<p className="text-sm text-[#888] text-center animate-pulse-slow">En attente que l&apos;hôte démarre...</p>
<p className="text-sm text-[#888] text-center animate-pulse-slow">
En attente que l&apos;hôte démarre...
</p>
)}
</div>
);
+2 -2
View File
@@ -67,8 +67,6 @@ export function ScreenRouter({
<HomeScreen
playerName={playerName} setPlayerName={setPlayerName}
joinCode={joinCode} setJoinCode={setJoinCode}
maxPlayers={maxPlayers} setMaxPlayers={setMaxPlayers}
totalRounds={totalRounds} setTotalRounds={setTotalRounds}
error={error} setError={setError} loading={loading}
onCreateRoom={handlers.handleCreateRoom}
onJoinRoom={handlers.handleJoinRoom}
@@ -108,6 +106,8 @@ export function ScreenRouter({
onLeave={handlers.handleLeave}
onStart={handlers.handleStartGame}
onReset={handlers.handleResetGame}
maxPlayers={maxPlayers} setMaxPlayers={setMaxPlayers}
totalRounds={totalRounds} setTotalRounds={setTotalRounds}
/>
);
+17 -7
View File
@@ -60,25 +60,35 @@ export default function MentionsLegalesPage() {
</h2>
<div className="bg-[#1a1a1a] border border-[#2e2e2e] rounded-xl p-4 flex flex-col gap-1.5 text-sm">
<p>
<span className="text-[#888]">Raison sociale :</span>{" "}
QuantumCraft Studios
<span className="text-[#888]">Raison sociale :</span> SAS
SAPINET
</p>
<p>
<span className="text-[#888]">SIRET :</span> 932 107 758 00017
<span className="text-[#888]">SIREN :</span> 899 483 457 (RCS
de Nanterre)
</p>
<p>
<span className="text-[#888]">Adresse :</span> 58 Rue de
Monceau, 75008 Paris, France
<span className="text-[#888]">Adresse :</span> 65 rue de la
Croix, 92000 Nanterre
</p>
<p>
<span className="text-[#888]">Contact :</span>{" "}
<a
href="mailto:contact@sapi.net"
className="text-[#7c3aed] hover:underline"
>
contact@sapi.net
</a>
</p>
<p>
<span className="text-[#888]">Site web :</span>{" "}
<a
href="https://www.quantumcraft-studios.com"
href="https://sapi.net"
target="_blank"
rel="noopener noreferrer"
className="text-[#7c3aed] hover:underline"
>
quantumcraft-studios.com
sapi.net
</a>
</p>
</div>