This commit is contained in:
NextJS
2026-05-05 20:02:40 +00:00
commit 0f6e979aac
51 changed files with 10161 additions and 0 deletions
+594
View File
@@ -0,0 +1,594 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
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 { DbMatch, DbUser } from "@/types/database";
/** 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;
}
}
type Props = {
users: DbUser[];
matches: DbMatch[];
usersError: string | null;
matchesError: string | null;
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,
statsBundle,
tab,
highlightId,
participantRaw,
matchmakerSource,
matchmakerContent,
matchmakerError,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
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="/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 winnings
</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>
<th className="px-4 py-3 font-medium">#</th>
<th className="px-4 py-3 font-medium">Player</th>
<th className="px-4 py-3 font-medium text-right">
Total prize CC
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={3}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
</td>
</tr>
) : (
(statsBundle?.leaderboard ?? []).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">
<Link
href={`/?tab=players&highlight=${row.userId}`}
className="font-mono text-xs text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
{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>
)}
</Link>
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.totalPrizeCc.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">{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>
) : (
<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)}
entryFee={m.entry_fee}
prizeCc={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>
);
}