847 lines
30 KiB
TypeScript
847 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
|
import { AdminLedger } from "@/components/admin-ledger";
|
|
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
|
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
|
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
|
import {
|
|
buildDashboardHref,
|
|
type AdminDashboardTab,
|
|
} from "@/lib/dashboard-search-url";
|
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
|
import type {
|
|
AdminMatchRow,
|
|
DbMatch,
|
|
DbTransaction,
|
|
DbUser,
|
|
} from "@/types/database";
|
|
import {
|
|
txAmountToBigInt,
|
|
type SerializedLedgerGlobalSummary,
|
|
} from "@/lib/ledger-integrity";
|
|
import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
|
|
|
|
/** Bigint columns often arrive as strings from PostgREST / JSON. */
|
|
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
|
|
if (winnerId == null) return false;
|
|
if (typeof winnerId === "number") {
|
|
return Number.isInteger(winnerId) && winnerId > 0;
|
|
}
|
|
if (typeof winnerId === "string") {
|
|
const t = winnerId.trim();
|
|
if (t === "" || !/^-?\d+$/.test(t)) return false;
|
|
const n = Number(t);
|
|
return Number.isFinite(n) && n > 0;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function participantInMatch(m: DbMatch, participantId: number): boolean {
|
|
const red = m.user_red;
|
|
const blue = m.user_blue;
|
|
if (red != null && Number(red) === participantId) return true;
|
|
if (blue != null && Number(blue) === participantId) return true;
|
|
return false;
|
|
}
|
|
|
|
function coalesceUserId(v: number | string | null): number | null {
|
|
if (v == null) return null;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? Math.trunc(n) : null;
|
|
}
|
|
|
|
/** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */
|
|
function formatTs(value: string | null): string {
|
|
if (!value) return "—";
|
|
try {
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return value;
|
|
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
|
|
if (v == null) return "—";
|
|
const b = txAmountToBigInt(v);
|
|
try {
|
|
return b.toLocaleString("en-US");
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|
|
|
|
type LeaderboardSortKey = "rank" | "player" | "rc" | "winRate" | "wins";
|
|
|
|
function playerSortKey(row: LeaderboardRow): string {
|
|
const t = row.username?.trim();
|
|
if (t) return t.toLowerCase();
|
|
return "\uffff";
|
|
}
|
|
|
|
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
|
|
if (key === "player" || key === "rank") return "asc";
|
|
return "desc";
|
|
}
|
|
|
|
function sortLeaderboardRows(
|
|
rows: LeaderboardRow[],
|
|
key: LeaderboardSortKey,
|
|
dir: "asc" | "desc",
|
|
): LeaderboardRow[] {
|
|
const out = [...rows];
|
|
out.sort((a, b) => {
|
|
let cmp = 0;
|
|
switch (key) {
|
|
case "rank":
|
|
cmp = a.winsLeaderboardRank - b.winsLeaderboardRank;
|
|
break;
|
|
case "wins":
|
|
cmp = a.matchesWon - b.matchesWon;
|
|
break;
|
|
case "player": {
|
|
const ka = playerSortKey(a);
|
|
const kb = playerSortKey(b);
|
|
cmp = ka.localeCompare(kb, "en", { sensitivity: "base" });
|
|
if (cmp === 0) cmp = a.userId - b.userId;
|
|
break;
|
|
}
|
|
case "rc": {
|
|
const aOk = a.rcBalance != null && Number.isFinite(a.rcBalance);
|
|
const bOk = b.rcBalance != null && Number.isFinite(b.rcBalance);
|
|
if (!aOk && !bOk) cmp = 0;
|
|
else if (!aOk) cmp = 1;
|
|
else if (!bOk) cmp = -1;
|
|
else cmp = a.rcBalance! - b.rcBalance!;
|
|
break;
|
|
}
|
|
case "winRate": {
|
|
const aOk =
|
|
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
|
|
const bOk =
|
|
b.winRatePercent != null && Number.isFinite(b.winRatePercent);
|
|
if (!aOk && !bOk) cmp = 0;
|
|
else if (!aOk) cmp = 1;
|
|
else if (!bOk) cmp = -1;
|
|
else cmp = a.winRatePercent! - b.winRatePercent!;
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
if (cmp !== 0) return dir === "asc" ? cmp : -cmp;
|
|
return a.userId - b.userId;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function LeaderboardSortTh({
|
|
label,
|
|
sortKey,
|
|
activeKey,
|
|
activeDir,
|
|
onActivate,
|
|
align = "left",
|
|
}: {
|
|
label: string;
|
|
sortKey: LeaderboardSortKey;
|
|
activeKey: LeaderboardSortKey;
|
|
activeDir: "asc" | "desc";
|
|
onActivate: (key: LeaderboardSortKey) => void;
|
|
align?: "left" | "right";
|
|
}) {
|
|
const active = activeKey === sortKey;
|
|
const rowAlign =
|
|
align === "right" ? "text-right" : "text-left";
|
|
const btnAlign =
|
|
align === "right"
|
|
? "justify-end text-right"
|
|
: "justify-start text-left";
|
|
return (
|
|
<th
|
|
scope="col"
|
|
className={`px-4 py-3 font-medium ${rowAlign}`}
|
|
aria-sort={
|
|
active ? (activeDir === "asc" ? "ascending" : "descending") : "none"
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={`Sort by ${label}`}
|
|
className={`-mx-1 flex w-full items-center gap-1 rounded px-1 py-0.5 text-inherit hover:bg-zinc-200/80 dark:hover:bg-zinc-700/80 ${btnAlign}`}
|
|
onClick={() => onActivate(sortKey)}
|
|
>
|
|
<span>{label}</span>
|
|
{active ? (
|
|
<span className="shrink-0 tabular-nums text-zinc-400" aria-hidden>
|
|
{activeDir === "asc" ? "↑" : "↓"}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</th>
|
|
);
|
|
}
|
|
|
|
type Props = {
|
|
users: DbUser[];
|
|
matches: AdminMatchRow[];
|
|
usersError: string | null;
|
|
matchesError: string | null;
|
|
transactionsError: string | null;
|
|
/** Ledger tab: full-database totals + user nets (server scan). */
|
|
ledgerGlobalSummary: SerializedLedgerGlobalSummary | null;
|
|
/** Ledger tab: rows in the selected UTC date range (full set for audits). */
|
|
ledgerTransactionsFiltered: DbTransaction[];
|
|
/** Ledger tab: current page of the ledger table (sorted + paginated). */
|
|
ledgerTableRows: DbTransaction[];
|
|
ledgerTotalRowsInRange: number;
|
|
ledgerPage: number;
|
|
ledgerPageSize: number;
|
|
ledgerTotalPages: number;
|
|
ledgerSort: LedgerSortKey;
|
|
ledgerOrder: LedgerSortOrder;
|
|
ledgerFrom: string;
|
|
ledgerTo: string;
|
|
statsBundle: DashboardStatsBundle | null;
|
|
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
|
|
tab: AdminDashboardTab;
|
|
highlightId: string | null;
|
|
participantRaw: string | null;
|
|
matchmakerSource: MatchmakerLogSource;
|
|
matchmakerContent: string;
|
|
matchmakerError: string | null;
|
|
};
|
|
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
}: {
|
|
title: string;
|
|
value: number | null;
|
|
}) {
|
|
const display =
|
|
value == null ? "—" : value.toLocaleString("en-US");
|
|
return (
|
|
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
<div className="flex flex-col items-center text-center">
|
|
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
|
{title}
|
|
</p>
|
|
<p className="mt-1.5 text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
|
|
{display}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AdminDashboard({
|
|
users,
|
|
matches,
|
|
usersError,
|
|
matchesError,
|
|
transactionsError,
|
|
ledgerGlobalSummary,
|
|
ledgerTransactionsFiltered,
|
|
ledgerTableRows,
|
|
ledgerTotalRowsInRange,
|
|
ledgerPage,
|
|
ledgerPageSize,
|
|
ledgerTotalPages,
|
|
ledgerSort,
|
|
ledgerOrder,
|
|
ledgerFrom,
|
|
ledgerTo,
|
|
statsBundle,
|
|
tab,
|
|
highlightId,
|
|
participantRaw,
|
|
matchmakerSource,
|
|
matchmakerContent,
|
|
matchmakerError,
|
|
}: Props) {
|
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
|
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
|
|
|
const lbRaw = statsBundle?.leaderboard;
|
|
const sortedLeaderboard = useMemo(() => {
|
|
const rows = lbRaw ?? [];
|
|
if (rows.length === 0) return [];
|
|
return sortLeaderboardRows(rows, lbSortKey, lbSortDir);
|
|
}, [lbRaw, lbSortKey, lbSortDir]);
|
|
|
|
const onLbSort = useCallback((key: LeaderboardSortKey) => {
|
|
setLbSortKey((prevKey) => {
|
|
if (key === prevKey) {
|
|
setLbSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
return prevKey;
|
|
}
|
|
setLbSortDir(leaderboardDefaultSortDir(key));
|
|
return key;
|
|
});
|
|
}, []);
|
|
|
|
const userById = useMemo(() => {
|
|
const m = new Map<number, DbUser>();
|
|
for (const u of users) {
|
|
m.set(u.id, u);
|
|
}
|
|
return m;
|
|
}, [users]);
|
|
|
|
const usernameById = useMemo(() => {
|
|
const m = new Map<number, string | null>();
|
|
for (const u of users) {
|
|
m.set(u.id, u.username);
|
|
}
|
|
return m;
|
|
}, [users]);
|
|
|
|
const participantId = useMemo(() => {
|
|
if (!participantRaw) return null;
|
|
const n = Number(participantRaw);
|
|
return Number.isFinite(n) ? n : null;
|
|
}, [participantRaw]);
|
|
|
|
const filteredMatches = useMemo(() => {
|
|
if (participantId == null) return matches;
|
|
return matches.filter((m) => participantInMatch(m, participantId));
|
|
}, [matches, participantId]);
|
|
const visibleMatches = useMemo(() => {
|
|
if (!hideNoWinner) return filteredMatches;
|
|
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
|
}, [filteredMatches, hideNoWinner]);
|
|
|
|
useEffect(() => {
|
|
if (tab !== "players" || !highlightId) return;
|
|
const el = document.getElementById(`player-row-${highlightId}`);
|
|
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
}, [tab, highlightId]);
|
|
|
|
const tabClass = (active: boolean) =>
|
|
[
|
|
"inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition",
|
|
active
|
|
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
|
|
: "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700",
|
|
].join(" ");
|
|
|
|
function editHref(u: DbUser): string {
|
|
return buildDashboardHref({
|
|
tab,
|
|
highlightId,
|
|
participantRaw,
|
|
editId: u.id,
|
|
});
|
|
}
|
|
|
|
const totalUsersLabel =
|
|
statsBundle?.stats?.totalUsers ?? users.length;
|
|
const totalMatchesLabel =
|
|
statsBundle?.stats?.totalMatches ?? matches.length;
|
|
const statusLabel = (status: number | null) => {
|
|
if (status === 2) return "Final";
|
|
if (status === 1) return "Live";
|
|
if (status === 0) return "Waiting";
|
|
return status == null ? "Unknown" : `Status ${status}`;
|
|
};
|
|
|
|
return (
|
|
<main className="flex-1 space-y-6 px-6 py-8">
|
|
<div className="flex flex-wrap gap-2 border-b border-zinc-200 pb-4 dark:border-zinc-800">
|
|
<Link
|
|
href={buildDashboardHref({
|
|
tab: "dashboard",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
className={tabClass(tab === "dashboard")}
|
|
scroll={false}
|
|
>
|
|
Overview
|
|
</Link>
|
|
<Link
|
|
href={buildDashboardHref({
|
|
tab: "players",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
className={tabClass(tab === "players")}
|
|
scroll={false}
|
|
>
|
|
Players (
|
|
{totalUsersLabel.toLocaleString("en-US")}
|
|
{!statsBundle?.stats
|
|
? ` · table ${users.length.toLocaleString("en-US")}`
|
|
: null}
|
|
)
|
|
</Link>
|
|
<Link
|
|
href={buildDashboardHref({
|
|
tab: "matches",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
className={tabClass(tab === "matches")}
|
|
scroll={false}
|
|
>
|
|
Matches (
|
|
{totalMatchesLabel.toLocaleString("en-US")}
|
|
{!statsBundle?.stats
|
|
? ` · table ${matches.length.toLocaleString("en-US")}`
|
|
: null}
|
|
)
|
|
</Link>
|
|
<Link
|
|
href={buildDashboardHref({
|
|
tab: "matchmaker",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
className={tabClass(tab === "matchmaker")}
|
|
scroll={false}
|
|
>
|
|
Matchmaker
|
|
</Link>
|
|
<Link
|
|
href={buildDashboardHref({
|
|
tab: "ledger",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
className={tabClass(tab === "ledger")}
|
|
scroll={false}
|
|
>
|
|
Ledger
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className={tabClass(false)}
|
|
scroll={false}
|
|
>
|
|
Settings
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mx-auto w-full max-w-[1400px]">
|
|
{tab === "dashboard" ? (
|
|
<section className="space-y-8">
|
|
{statsBundle?.error ? (
|
|
<div
|
|
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
|
role="status"
|
|
>
|
|
{statsBundle.error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-8 xl:grid-cols-2">
|
|
<div>
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
Active players
|
|
</h2>
|
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
|
<StatCard
|
|
title="Active players today"
|
|
value={statsBundle?.stats?.activePlayersToday ?? null}
|
|
/>
|
|
<StatCard
|
|
title="Active players (last 7 days)"
|
|
value={statsBundle?.stats?.activePlayersLastWeek ?? null}
|
|
/>
|
|
<StatCard
|
|
title="Active players (last 30 days)"
|
|
value={statsBundle?.stats?.activePlayersLastMonth ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
Matches
|
|
</h2>
|
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
|
<StatCard
|
|
title="Matches today"
|
|
value={statsBundle?.stats?.matchesToday ?? null}
|
|
/>
|
|
<StatCard
|
|
title="Matches (last 7 days)"
|
|
value={statsBundle?.stats?.matchesLastWeek ?? null}
|
|
/>
|
|
<StatCard
|
|
title="Matches (last 30 days)"
|
|
value={statsBundle?.stats?.matchesLastMonth ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<StatCard
|
|
title="Total users"
|
|
value={statsBundle?.stats?.totalUsers ?? null}
|
|
/>
|
|
<StatCard
|
|
title="Total matches"
|
|
value={statsBundle?.stats?.totalMatches ?? null}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
Top players by match wins
|
|
</h2>
|
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
<table className="min-w-full text-left text-sm">
|
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
|
|
<tr>
|
|
<LeaderboardSortTh
|
|
label="#"
|
|
sortKey="rank"
|
|
activeKey={lbSortKey}
|
|
activeDir={lbSortDir}
|
|
onActivate={onLbSort}
|
|
/>
|
|
<LeaderboardSortTh
|
|
label="Player"
|
|
sortKey="player"
|
|
activeKey={lbSortKey}
|
|
activeDir={lbSortDir}
|
|
onActivate={onLbSort}
|
|
/>
|
|
<LeaderboardSortTh
|
|
label="RC balance"
|
|
sortKey="rc"
|
|
activeKey={lbSortKey}
|
|
activeDir={lbSortDir}
|
|
onActivate={onLbSort}
|
|
/>
|
|
<LeaderboardSortTh
|
|
label="Win rate"
|
|
sortKey="winRate"
|
|
activeKey={lbSortKey}
|
|
activeDir={lbSortDir}
|
|
onActivate={onLbSort}
|
|
align="right"
|
|
/>
|
|
<LeaderboardSortTh
|
|
label="Wins"
|
|
sortKey="wins"
|
|
activeKey={lbSortKey}
|
|
activeDir={lbSortDir}
|
|
onActivate={onLbSort}
|
|
align="right"
|
|
/>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={5}
|
|
className="px-4 py-8 text-center text-zinc-500"
|
|
>
|
|
No wins recorded yet (no rows with a winner).
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedLeaderboard.map((row, i) => (
|
|
<tr
|
|
key={row.userId}
|
|
className="text-zinc-800 dark:text-zinc-200"
|
|
>
|
|
<td className="px-4 py-2 font-mono text-xs text-zinc-500">
|
|
{i + 1}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<span className="font-mono text-xs">
|
|
<ClickableUserId id={row.userId} />
|
|
{row.username != null && row.username !== "" ? (
|
|
<span className="text-zinc-700 dark:text-zinc-300">
|
|
{" "}
|
|
({row.username})
|
|
</span>
|
|
) : (
|
|
<span className="text-zinc-500"> (—)</span>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
|
|
{formatRcBalanceWithCoins(row.rcBalance)}
|
|
</td>
|
|
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
|
{row.winRatePercent == null
|
|
? "—"
|
|
: `${row.winRatePercent.toLocaleString("en-US", {
|
|
maximumFractionDigits: 1,
|
|
minimumFractionDigits: 0,
|
|
})}%`}
|
|
</td>
|
|
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
|
{row.matchesWon.toLocaleString("en-US")}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : tab === "players" ? (
|
|
<section>
|
|
{usersError ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
|
|
) : (
|
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
<table className="min-w-full text-left text-sm">
|
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
|
|
<tr>
|
|
<th className="px-4 py-3 font-medium">ID</th>
|
|
<th className="px-4 py-3 font-medium">Username</th>
|
|
<th className="px-4 py-3 font-medium">CC</th>
|
|
<th className="px-4 py-3 font-medium">RC</th>
|
|
<th className="px-4 py-3 font-medium">Created</th>
|
|
<th className="px-4 py-3 font-medium">Last seen</th>
|
|
<th className="px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
|
{users.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={7}
|
|
className="px-4 py-8 text-center text-zinc-500"
|
|
>
|
|
No players yet.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
users.map((u) => {
|
|
const isHi =
|
|
highlightId != null &&
|
|
highlightId === String(u.id);
|
|
return (
|
|
<tr
|
|
key={u.id}
|
|
id={`player-row-${u.id}`}
|
|
className={
|
|
isHi
|
|
? "bg-amber-100/90 ring-2 ring-inset ring-amber-400/80 dark:bg-amber-950/50 dark:ring-amber-500/60"
|
|
: "text-zinc-800 dark:text-zinc-200"
|
|
}
|
|
>
|
|
<td className="px-4 py-2 font-mono text-xs">
|
|
<ClickableUserId id={u.id} />
|
|
</td>
|
|
<td className="px-4 py-2">{u.username ?? "—"}</td>
|
|
<td className="px-4 py-2">{u.cc ?? "—"}</td>
|
|
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
|
<td className="px-4 py-2 whitespace-nowrap">
|
|
{formatTs(u.created_at)}
|
|
</td>
|
|
<td className="px-4 py-2 whitespace-nowrap">
|
|
{formatTs(u.last_logged_at)}
|
|
</td>
|
|
<td className="px-4 py-2 whitespace-nowrap">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Link
|
|
href={editHref(u)}
|
|
scroll={false}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
|
>
|
|
Edit
|
|
</Link>
|
|
<Link
|
|
href={`/?tab=matches&participant=${u.id}`}
|
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
|
scroll={false}
|
|
>
|
|
Show matches
|
|
</Link>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : tab === "matchmaker" ? (
|
|
<section className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
|
<p>
|
|
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
|
history.log
|
|
</span>{" "}
|
|
is the processed summary;{" "}
|
|
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
|
matchmaker.log
|
|
</span>{" "}
|
|
is raw output. Toggle below or{" "}
|
|
<Link
|
|
href={`/matchmaker-logs${matchmakerSource === "raw" ? "?mklog=raw" : ""}`}
|
|
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
open fullscreen
|
|
</Link>
|
|
.
|
|
</p>
|
|
</div>
|
|
<MatchmakerLogTerminal
|
|
layout="embedded"
|
|
source={matchmakerSource}
|
|
processedHref={buildDashboardHref({
|
|
tab: "matchmaker",
|
|
highlightId,
|
|
participantRaw,
|
|
})}
|
|
rawHref={buildDashboardHref({
|
|
tab: "matchmaker",
|
|
highlightId,
|
|
participantRaw,
|
|
matchmakerSource: "raw",
|
|
})}
|
|
content={matchmakerContent}
|
|
errorMessage={matchmakerError}
|
|
/>
|
|
</section>
|
|
) : tab === "ledger" ? (
|
|
<AdminLedger
|
|
ledgerGlobalSummary={ledgerGlobalSummary}
|
|
transactionsScoped={ledgerTransactionsFiltered}
|
|
ledgerTableRows={ledgerTableRows}
|
|
ledgerTotalRowsInRange={ledgerTotalRowsInRange}
|
|
ledgerPage={ledgerPage}
|
|
ledgerPageSize={ledgerPageSize}
|
|
ledgerTotalPages={ledgerTotalPages}
|
|
ledgerSort={ledgerSort}
|
|
ledgerOrder={ledgerOrder}
|
|
users={users}
|
|
error={transactionsError}
|
|
highlightId={highlightId}
|
|
participantRaw={participantRaw}
|
|
ledgerFrom={ledgerFrom}
|
|
ledgerTo={ledgerTo}
|
|
/>
|
|
) : (
|
|
<section className="space-y-3">
|
|
{participantId != null ? (
|
|
<div
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
|
|
role="status"
|
|
>
|
|
<span>
|
|
Showing matches where{" "}
|
|
<span className="font-mono font-medium">
|
|
{participantId}
|
|
</span>
|
|
{usernameById.get(participantId) != null &&
|
|
usernameById.get(participantId) !== "" ? (
|
|
<span>
|
|
{" "}
|
|
({usernameById.get(participantId)})
|
|
</span>
|
|
) : null}{" "}
|
|
is the Red or Blue user (
|
|
{filteredMatches.length} of {matches.length})
|
|
</span>
|
|
<Link
|
|
href="/?tab=matches"
|
|
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
|
|
scroll={false}
|
|
>
|
|
Clear filter
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
|
|
{matchesError ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{matchesError}
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-3">
|
|
<div className="relative z-10 mb-1 flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
<label
|
|
htmlFor="admin-hide-matches-no-winner"
|
|
className="inline-flex cursor-pointer items-center gap-2 text-zinc-700 dark:text-zinc-200"
|
|
>
|
|
<input
|
|
id="admin-hide-matches-no-winner"
|
|
type="checkbox"
|
|
checked={hideNoWinner}
|
|
onChange={(e) => setHideNoWinner(e.target.checked)}
|
|
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
|
|
/>
|
|
Hide matches with no winner
|
|
</label>
|
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
Showing {visibleMatches.length} of {filteredMatches.length}
|
|
</span>
|
|
</div>
|
|
{visibleMatches.length === 0 ? (
|
|
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
{filteredMatches.length === 0
|
|
? "No matches yet."
|
|
: hideNoWinner
|
|
? "No matches left after hiding no-winner matches."
|
|
: "No matches for this filter."}
|
|
</div>
|
|
) : (
|
|
visibleMatches.map((m) => {
|
|
const redId = coalesceUserId(m.user_red);
|
|
const blueId = coalesceUserId(m.user_blue);
|
|
const leftUser =
|
|
redId != null ? userById.get(redId) : null;
|
|
const rightUser =
|
|
blueId != null ? userById.get(blueId) : null;
|
|
return (
|
|
<MatchHistoryBattleCard
|
|
key={m.id}
|
|
matchId={m.id}
|
|
statusLabel={statusLabel(m.status)}
|
|
createdAtLabel={formatTs(m.created_at)}
|
|
entryFeeCoins={m.entryFeePerPlayerCoins}
|
|
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
|
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
|
winnerId={m.winner_id}
|
|
left={{
|
|
id: redId,
|
|
username: leftUser?.username ?? null,
|
|
cc: leftUser?.cc ?? null,
|
|
rc: leftUser?.rc ?? null,
|
|
color: "red",
|
|
}}
|
|
right={{
|
|
id: blueId,
|
|
username: rightUser?.username ?? null,
|
|
cc: rightUser?.cc ?? null,
|
|
rc: rightUser?.rc ?? null,
|
|
color: "blue",
|
|
}}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|