143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
|
|
type CheatFlag = {
|
|
id: number;
|
|
user_id: string;
|
|
pseudo: string;
|
|
game: "icarus" | "melon";
|
|
score: number;
|
|
severity: "warning" | "high";
|
|
trigger_code: string;
|
|
reason: string;
|
|
details: {
|
|
duration_ms?: number | null;
|
|
actions?: number | null;
|
|
merges?: number | null;
|
|
previous_best?: number | null;
|
|
} | null;
|
|
created_at: string;
|
|
};
|
|
|
|
const GAME_LABELS: Record<CheatFlag["game"], string> = {
|
|
icarus: "Vol d'Icare",
|
|
melon: "Corne d'Abondance",
|
|
};
|
|
|
|
function formatDuration(durationMs: number | null | undefined): string | null {
|
|
if (durationMs == null || !Number.isFinite(durationMs)) return null;
|
|
if (durationMs < 1000) return `${Math.round(durationMs)} ms`;
|
|
const seconds = durationMs / 1000;
|
|
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)} s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = Math.round(seconds % 60);
|
|
return `${minutes} min ${remainingSeconds.toString().padStart(2, "0")} s`;
|
|
}
|
|
|
|
function FlagRow({ flag }: { flag: CheatFlag }) {
|
|
const duration = formatDuration(flag.details?.duration_ms);
|
|
const happenedAt = new Intl.DateTimeFormat("fr-FR", {
|
|
timeZone: "Europe/Paris",
|
|
dateStyle: "short",
|
|
timeStyle: "medium",
|
|
}).format(new Date(flag.created_at));
|
|
|
|
return (
|
|
<li className="marble-surface rounded-lg border border-gold/25 px-4 py-3 shadow-sm">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={
|
|
flag.severity === "high"
|
|
? "rounded-full border border-oxblood/40 bg-oxblood/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-oxblood uppercase"
|
|
: "rounded-full border border-gold/40 bg-gold/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-text-marble uppercase"
|
|
}
|
|
>
|
|
{flag.severity === "high" ? "Fort" : "À vérifier"}
|
|
</span>
|
|
<span className="font-heading text-sm text-text-marble">{flag.pseudo}</span>
|
|
<span className="text-xs text-text-mut">· {GAME_LABELS[flag.game]}</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-text-marble">{flag.reason}</p>
|
|
</div>
|
|
|
|
<div className="text-right">
|
|
<p className="font-heading text-lg text-sea">{flag.score}</p>
|
|
<p className="text-[0.7rem] text-text-mut">{happenedAt}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-mut">
|
|
{duration && <span>durée : {duration}</span>}
|
|
{flag.details?.actions != null && <span>actions : {flag.details.actions}</span>}
|
|
{flag.details?.merges != null && <span>fusions : {flag.details.merges}</span>}
|
|
{flag.details?.previous_best != null && <span>ancien record : {flag.details.previous_best}</span>}
|
|
<span className="font-mono opacity-60">{flag.trigger_code}</span>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function AdminCheatFlags({ flags }: { flags: CheatFlag[] }) {
|
|
const [game, setGame] = useState<"all" | CheatFlag["game"]>("all");
|
|
|
|
const filtered = useMemo(
|
|
() => (game === "all" ? flags : flags.filter((flag) => flag.game === game)),
|
|
[flags, game],
|
|
);
|
|
const flaggedUsers = useMemo(() => new Set(flags.map((flag) => flag.user_id)).size, [flags]);
|
|
|
|
return (
|
|
<section className="mt-10 border-t border-gold/20 pt-8">
|
|
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<h2 className="font-heading text-lg tracking-wide text-gold-bright uppercase">Détection de triche</h2>
|
|
<p className="mt-1 max-w-xl text-sm text-marble/60">
|
|
Les scores restent acceptés. Cette liste signale seulement les parties qui ont déclenché un contrôle
|
|
simple côté serveur.
|
|
</p>
|
|
</div>
|
|
<p className="text-xs text-marble/60">
|
|
{flags.length} signalement{flags.length > 1 ? "s" : ""} · {flaggedUsers} joueur
|
|
{flaggedUsers > 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap gap-2">
|
|
{([
|
|
["all", "Tous"],
|
|
["icarus", "Icare"],
|
|
["melon", "Corne"],
|
|
] as const).map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setGame(value)}
|
|
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
|
|
game === value
|
|
? "border-gold/60 bg-gold/15 text-gold-bright"
|
|
: "border-gold/25 text-marble/70 hover:bg-gold/10"
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<p className="rounded-lg border border-gold/20 px-4 py-5 text-center text-sm text-marble/60">
|
|
Aucun signalement pour ce filtre.
|
|
</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{filtered.map((flag) => (
|
|
<FlagRow key={flag.id} flag={flag} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|