Améliore la lisibilité de la Détection de triche sur /admin
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
Ajoute l'avatar du joueur signalé (admin_list_members() le renvoyait déjà, juste jamais propagé), l'heure de début/fin de la partie (déduite de created_at et de la durée annoncée), et remplace la ligne de détails en flex-wrap par une grille à étiquettes qui reste lisible sur mobile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Avatar } from "@/components/avatar";
|
||||||
|
|
||||||
type CheatFlag = {
|
type CheatFlag = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
pseudo: string;
|
pseudo: string;
|
||||||
|
avatar_url: string | null;
|
||||||
game: "icarus" | "melon";
|
game: "icarus" | "melon";
|
||||||
score: number;
|
score: number;
|
||||||
severity: "warning" | "high";
|
severity: "warning" | "high";
|
||||||
@@ -25,6 +27,16 @@ const GAME_LABELS: Record<CheatFlag["game"], string> = {
|
|||||||
melon: "Corne d'Abondance",
|
melon: "Corne d'Abondance",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIME_FORMATTER = new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
timeZone: "Europe/Paris",
|
||||||
|
timeStyle: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const DATE_FORMATTER = new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
timeZone: "Europe/Paris",
|
||||||
|
dateStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
function formatDuration(durationMs: number | null | undefined): string | null {
|
function formatDuration(durationMs: number | null | undefined): string | null {
|
||||||
if (durationMs == null || !Number.isFinite(durationMs)) return null;
|
if (durationMs == null || !Number.isFinite(durationMs)) return null;
|
||||||
if (durationMs < 1000) return `${Math.round(durationMs)} ms`;
|
if (durationMs < 1000) return `${Math.round(durationMs)} ms`;
|
||||||
@@ -35,19 +47,35 @@ function formatDuration(durationMs: number | null | undefined): string | null {
|
|||||||
return `${minutes} min ${remainingSeconds.toString().padStart(2, "0")} s`;
|
return `${minutes} min ${remainingSeconds.toString().padStart(2, "0")} s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailCell({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[0.65rem] tracking-wide text-text-mut uppercase">{label}</dt>
|
||||||
|
<dd className="text-text-marble">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FlagRow({ flag }: { flag: CheatFlag }) {
|
function FlagRow({ flag }: { flag: CheatFlag }) {
|
||||||
const duration = formatDuration(flag.details?.duration_ms);
|
const duration = formatDuration(flag.details?.duration_ms);
|
||||||
const happenedAt = new Intl.DateTimeFormat("fr-FR", {
|
// Le signalement est écrit au moment de la soumission du score, donc
|
||||||
timeZone: "Europe/Paris",
|
// quasi simultané à la fin réelle de la partie (délai réseau négligeable)
|
||||||
dateStyle: "short",
|
// — sert directement de proxy pour "heure de fin". "Début" s'en déduit en
|
||||||
timeStyle: "medium",
|
// retranchant la durée annoncée, seulement quand elle est connue (jamais
|
||||||
}).format(new Date(flag.created_at));
|
// le cas pour missing_run_telemetry, où aucune télémétrie n'a été reçue).
|
||||||
|
const endedAt = new Date(flag.created_at);
|
||||||
|
const startedAt =
|
||||||
|
flag.details?.duration_ms != null && Number.isFinite(flag.details.duration_ms)
|
||||||
|
? new Date(endedAt.getTime() - flag.details.duration_ms)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="marble-surface rounded-lg border border-gold/25 px-4 py-3 shadow-sm">
|
<li className="marble-surface rounded-lg border border-gold/25 p-3 shadow-sm sm:p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<div>
|
<Avatar pseudo={flag.pseudo} avatarUrl={flag.avatar_url} size="sm" />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
flag.severity === "high"
|
flag.severity === "high"
|
||||||
@@ -57,24 +85,29 @@ function FlagRow({ flag }: { flag: CheatFlag }) {
|
|||||||
>
|
>
|
||||||
{flag.severity === "high" ? "Fort" : "À vérifier"}
|
{flag.severity === "high" ? "Fort" : "À vérifier"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-heading text-sm text-text-marble">{flag.pseudo}</span>
|
<span className="truncate font-heading text-sm text-text-marble">{flag.pseudo}</span>
|
||||||
<span className="text-xs text-text-mut">· {GAME_LABELS[flag.game]}</span>
|
<span className="text-xs text-text-mut">· {GAME_LABELS[flag.game]}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-text-marble">{flag.reason}</p>
|
<p className="mt-1 text-sm text-text-marble">{flag.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right">
|
<p className="shrink-0 font-heading text-lg text-sea">{flag.score}</p>
|
||||||
<p className="font-heading text-lg text-sea">{flag.score}</p>
|
|
||||||
<p className="text-[0.7rem] text-text-mut">{happenedAt}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-mut">
|
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 border-t border-gold/15 pt-3 text-xs sm:grid-cols-4">
|
||||||
{duration && <span>durée : {duration}</span>}
|
<DetailCell label="Début" value={startedAt ? TIME_FORMATTER.format(startedAt) : "—"} />
|
||||||
{flag.details?.actions != null && <span>actions : {flag.details.actions}</span>}
|
<DetailCell label="Fin" value={TIME_FORMATTER.format(endedAt)} />
|
||||||
{flag.details?.merges != null && <span>fusions : {flag.details.merges}</span>}
|
<DetailCell label="Durée" value={duration ?? "—"} />
|
||||||
{flag.details?.previous_best != null && <span>ancien record : {flag.details.previous_best}</span>}
|
{flag.details?.actions != null && <DetailCell label="Actions" value={String(flag.details.actions)} />}
|
||||||
|
{flag.details?.merges != null && <DetailCell label="Fusions" value={String(flag.details.merges)} />}
|
||||||
|
{flag.details?.previous_best != null && (
|
||||||
|
<DetailCell label="Ancien record" value={String(flag.details.previous_best)} />
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[0.7rem] text-text-mut">
|
||||||
<span className="font-mono opacity-60">{flag.trigger_code}</span>
|
<span className="font-mono opacity-60">{flag.trigger_code}</span>
|
||||||
|
<span>{DATE_FORMATTER.format(endedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ export default async function AdminPage() {
|
|||||||
|
|
||||||
const memberRows = (members ?? []) as AdminMember[];
|
const memberRows = (members ?? []) as AdminMember[];
|
||||||
const flagRows = (cheatFlags ?? []) as CheatFlagRow[];
|
const flagRows = (cheatFlags ?? []) as CheatFlagRow[];
|
||||||
const pseudoById = new Map(memberRows.map((member) => [member.id, member.pseudo]));
|
const memberById = new Map(memberRows.map((member) => [member.id, member]));
|
||||||
const flagsWithPseudo = flagRows.map((flag) => ({
|
const flagsWithPseudo = flagRows.map((flag) => ({
|
||||||
...flag,
|
...flag,
|
||||||
pseudo: pseudoById.get(flag.user_id) ?? "Un Citoyen",
|
pseudo: memberById.get(flag.user_id)?.pseudo ?? "Un Citoyen",
|
||||||
|
avatar_url: memberById.get(flag.user_id)?.avatar_url ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user