init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
export function AdminHeader() {
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Kick Kings Admin Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void logout()}
|
||||
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
insertSetting,
|
||||
updateSetting,
|
||||
} from "@/app/actions/settings-actions";
|
||||
import {
|
||||
coinsToRc,
|
||||
parseStoredCoins,
|
||||
rcToCoins,
|
||||
} from "@/lib/coins-rc";
|
||||
import type { DbSetting } from "@/types/database";
|
||||
|
||||
type Props = {
|
||||
rows: DbSetting[];
|
||||
saveError: boolean;
|
||||
addError: string | null;
|
||||
};
|
||||
|
||||
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
return (
|
||||
<form
|
||||
action={updateSetting}
|
||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`setting-value-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`setting-value-${index}`}
|
||||
name="value"
|
||||
type="text"
|
||||
defaultValue={row.value ?? ""}
|
||||
autoComplete="off"
|
||||
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
const initial = Math.min(
|
||||
100,
|
||||
Math.max(0, Math.round(Number(row.value ?? 0))),
|
||||
);
|
||||
const [v, setV] = useState(initial);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={updateSetting}
|
||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input type="hidden" name="value" value={String(v)} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`bet-fee-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
0–100 (saved as the number shown).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
id={`bet-fee-${index}`}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
const initialCoins = parseStoredCoins(row.value);
|
||||
const initialRc = coinsToRc(initialCoins);
|
||||
const [rcText, setRcText] = useState(() => initialRc.toFixed(1));
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const coinsPreview = useMemo(() => {
|
||||
const t = rcText.trim();
|
||||
if (t === "") return null;
|
||||
return rcToCoins(Number(t));
|
||||
}, [rcText]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={updateSetting}
|
||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||
onSubmit={(e) => {
|
||||
setLocalError(null);
|
||||
const trimmed = rcText.trim();
|
||||
if (trimmed === "") {
|
||||
e.preventDefault();
|
||||
setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
|
||||
return;
|
||||
}
|
||||
const rc = Number(trimmed);
|
||||
const coins = rcToCoins(rc);
|
||||
if (coins === null) {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="value"
|
||||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`entry-fee-rc-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||
(edit as RC; stored as coins)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`entry-fee-rc-${index}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={rcText}
|
||||
onChange={(e) => setRcText(e.target.value)}
|
||||
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
{localError ? (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{localError}
|
||||
</p>
|
||||
) : coinsPreview !== null ? (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
|
||||
database.
|
||||
</p>
|
||||
) : rcText.trim() !== "" ? (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Not a valid RC encoding yet — fix the value to save.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
if (row.key === "bet_fee") {
|
||||
return <BetFeeRow row={row} index={index} />;
|
||||
}
|
||||
if (row.key === "entry_fee") {
|
||||
return <EntryFeeRow row={row} index={index} />;
|
||||
}
|
||||
return <DefaultSettingRow row={row} index={index} />;
|
||||
}
|
||||
|
||||
export function AdminSettingsEditor({
|
||||
rows,
|
||||
saveError,
|
||||
addError,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||
{saveError ? (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
|
||||
role="alert"
|
||||
>
|
||||
Could not save that row. Try again or check Supabase connectivity.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Existing keys
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
<p 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 dark:text-zinc-400">
|
||||
No settings rows yet. Add one below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, i) => (
|
||||
<SettingRow key={row.key} row={row} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Add setting
|
||||
</h2>
|
||||
|
||||
{addError === "duplicate" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
That key already exists. Edit it in the list above instead.
|
||||
</p>
|
||||
) : addError === "missing" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Enter a non-empty key.
|
||||
</p>
|
||||
) : addError === "config" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Supabase admin client is not configured.
|
||||
</p>
|
||||
) : addError === "other" ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
Could not insert. Check constraints and retry.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<form action={insertSetting} className="mt-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new-setting-key"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Key
|
||||
</label>
|
||||
<input
|
||||
id="new-setting-key"
|
||||
name="newKey"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="e.g. maintenance_message"
|
||||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="new-setting-value"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="new-setting-value"
|
||||
name="newValue"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Add row
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<Link
|
||||
href="/"
|
||||
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"
|
||||
scroll={false}
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Link from "next/link";
|
||||
import { updateUserCcRc } from "@/app/actions/update-user-cc-rc";
|
||||
import {
|
||||
buildDashboardHref,
|
||||
type AdminDashboardTab,
|
||||
} from "@/lib/dashboard-search-url";
|
||||
import type { DbUser } from "@/types/database";
|
||||
|
||||
type Props = {
|
||||
user: DbUser;
|
||||
tab: AdminDashboardTab;
|
||||
highlightId: string | null;
|
||||
participantRaw: string | null;
|
||||
saveError: boolean;
|
||||
};
|
||||
|
||||
export function EditUserCcRcOverlay({
|
||||
user,
|
||||
tab,
|
||||
highlightId,
|
||||
participantRaw,
|
||||
saveError,
|
||||
}: Props) {
|
||||
const cancelHref = buildDashboardHref({
|
||||
tab,
|
||||
highlightId,
|
||||
participantRaw,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-cc-rc-title"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2
|
||||
id="edit-cc-rc-title"
|
||||
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
|
||||
>
|
||||
Edit CC & RC
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Player{" "}
|
||||
<span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{user.id}
|
||||
</span>
|
||||
{user.username ? <span> ({user.username})</span> : null}
|
||||
</p>
|
||||
{saveError ? (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
Could not save. Check that CC and RC are valid numbers or empty.
|
||||
</p>
|
||||
) : null}
|
||||
<form action={updateUserCcRc} className="mt-5 space-y-4">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="tab"
|
||||
value={
|
||||
tab === "matches"
|
||||
? "matches"
|
||||
: tab === "players"
|
||||
? "players"
|
||||
: "dashboard"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="highlightId"
|
||||
value={highlightId ?? ""}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="participantRaw"
|
||||
value={participantRaw ?? ""}
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`edit-cc-${user.id}`}
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
CC
|
||||
</label>
|
||||
<input
|
||||
id={`edit-cc-${user.id}`}
|
||||
name="cc"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
defaultValue={user.cc == null ? "" : String(user.cc)}
|
||||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`edit-rc-${user.id}`}
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
RC
|
||||
</label>
|
||||
<input
|
||||
id={`edit-rc-${user.id}`}
|
||||
name="rc"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
defaultValue={user.rc == null ? "" : String(user.rc)}
|
||||
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Link
|
||||
href={cancelHref}
|
||||
scroll={false}
|
||||
className="inline-flex items-center justify-center rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm 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"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type PlayerSide = {
|
||||
id: number | null;
|
||||
username: string | null;
|
||||
cc: number | null;
|
||||
rc: number | null;
|
||||
color: "red" | "blue";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
matchId: number;
|
||||
statusLabel: string;
|
||||
createdAtLabel: string;
|
||||
entryFee: number | null;
|
||||
prizeCc: number | null;
|
||||
left: PlayerSide;
|
||||
right: PlayerSide;
|
||||
/** May be string when `bigint` is JSON-serialized. */
|
||||
winnerId: number | string | null;
|
||||
};
|
||||
|
||||
function idsMatch(
|
||||
a: number | string | null | undefined,
|
||||
b: number | string | null | undefined,
|
||||
): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
const na = typeof a === "number" ? a : Number(a);
|
||||
const nb = typeof b === "number" ? b : Number(b);
|
||||
return Number.isFinite(na) && Number.isFinite(nb) && na === nb;
|
||||
}
|
||||
|
||||
function sideStyles(color: PlayerSide["color"]) {
|
||||
if (color === "red") {
|
||||
return {
|
||||
panel:
|
||||
"border-rose-500/60 bg-gradient-to-br from-rose-700/80 via-fuchsia-700/70 to-violet-700/80",
|
||||
tag: "bg-rose-500/90 text-rose-50",
|
||||
iconBg: "bg-rose-500/30 text-rose-100",
|
||||
score: "text-rose-100",
|
||||
};
|
||||
}
|
||||
return {
|
||||
panel:
|
||||
"border-sky-500/60 bg-gradient-to-br from-sky-700/80 via-indigo-700/70 to-violet-700/80",
|
||||
tag: "bg-sky-500/90 text-sky-50",
|
||||
iconBg: "bg-sky-500/30 text-sky-100",
|
||||
score: "text-sky-100",
|
||||
};
|
||||
}
|
||||
|
||||
function initials(name: string | null): string {
|
||||
if (!name || name.trim() === "") return "?";
|
||||
const compact = name.trim().slice(0, 2);
|
||||
return compact.toUpperCase();
|
||||
}
|
||||
|
||||
function playerLabel(side: PlayerSide): string {
|
||||
if (side.id == null) return "Open slot";
|
||||
if (side.username && side.username.trim() !== "") return side.username;
|
||||
return `Player ${side.id}`;
|
||||
}
|
||||
|
||||
function winnerLabel(
|
||||
winnerId: number | string | null,
|
||||
left: PlayerSide,
|
||||
right: PlayerSide,
|
||||
): string {
|
||||
if (winnerId == null || winnerId === "") return "No winner yet";
|
||||
if (idsMatch(winnerId, left.id)) return `${playerLabel(left)} won`;
|
||||
if (idsMatch(winnerId, right.id)) return `${playerLabel(right)} won`;
|
||||
return `Winner #${winnerId}`;
|
||||
}
|
||||
|
||||
function PlayerPanel({
|
||||
side,
|
||||
isWinner,
|
||||
isDimmed,
|
||||
}: {
|
||||
side: PlayerSide;
|
||||
isWinner: boolean;
|
||||
isDimmed: boolean;
|
||||
}) {
|
||||
const s = sideStyles(side.color);
|
||||
return (
|
||||
<div
|
||||
className={`relative flex min-h-24 flex-col justify-between rounded-xl border px-2.5 py-2 shadow-lg shadow-black/20 transition ${s.panel} ${
|
||||
isWinner ? "ring-2 ring-amber-300 shadow-amber-200/30" : ""
|
||||
} ${isDimmed ? "opacity-50" : "opacity-100"}`}
|
||||
>
|
||||
{isWinner ? (
|
||||
<span className="absolute -top-2 right-2 rounded bg-amber-300 px-1.5 py-0.5 text-[10px] font-black uppercase tracking-wider text-amber-950">
|
||||
Winner
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`inline-flex w-fit rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${s.tag}`}
|
||||
>
|
||||
{side.color === "red" ? "Home" : "Away"}
|
||||
</span>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div
|
||||
className={`grid h-8 w-8 place-items-center rounded-full text-xs font-bold ${s.iconBg}`}
|
||||
>
|
||||
{initials(side.username)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-extrabold uppercase tracking-wide text-white">
|
||||
{playerLabel(side)}
|
||||
</p>
|
||||
<p className={`text-[11px] font-semibold tabular-nums ${s.score}`}>
|
||||
ID: {side.id ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] font-semibold text-white/85">
|
||||
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
||||
CC {side.cc ?? "—"}
|
||||
</span>
|
||||
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
||||
RC {side.rc ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchHistoryBattleCard({
|
||||
matchId,
|
||||
statusLabel,
|
||||
createdAtLabel,
|
||||
entryFee,
|
||||
prizeCc,
|
||||
left,
|
||||
right,
|
||||
winnerId,
|
||||
}: Props) {
|
||||
const hasWinner =
|
||||
winnerId != null &&
|
||||
winnerId !== "" &&
|
||||
(idsMatch(winnerId, left.id) || idsMatch(winnerId, right.id));
|
||||
const winnerIsRight = hasWinner && idsMatch(winnerId, right.id);
|
||||
const displayLeft = winnerIsRight ? right : left;
|
||||
const displayRight = winnerIsRight ? left : right;
|
||||
const leftIsWinner = hasWinner && idsMatch(displayLeft.id, winnerId);
|
||||
const rightIsWinner = hasWinner && idsMatch(displayRight.id, winnerId);
|
||||
const noWinner = !hasWinner;
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-zinc-200 bg-gradient-to-b from-zinc-100 to-zinc-200 p-2.5 shadow-sm dark:border-zinc-700 dark:from-zinc-900 dark:to-zinc-950">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-white dark:bg-zinc-50 dark:text-zinc-900">
|
||||
Match #{matchId}
|
||||
</span>
|
||||
<span className="rounded-md bg-amber-400/90 px-2 py-1 text-[11px] font-extrabold uppercase tracking-wider text-amber-950">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||
<PlayerPanel
|
||||
side={displayLeft}
|
||||
isWinner={leftIsWinner}
|
||||
isDimmed={noWinner ? true : !leftIsWinner}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="rounded-lg border-2 border-amber-300 bg-amber-200/95 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-amber-950">
|
||||
Final
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-400 bg-zinc-100 px-2 py-1 text-[11px] font-semibold text-zinc-700 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
{winnerLabel(winnerId, displayLeft, displayRight)}
|
||||
</div>
|
||||
</div>
|
||||
<PlayerPanel
|
||||
side={displayRight}
|
||||
isWinner={rightIsWinner}
|
||||
isDimmed={noWinner ? true : !rightIsWinner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-300/80 pt-1.5 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:text-zinc-300">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||
Entry {entryFee ?? "—"}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||
Prize {prizeCc ?? "—"} CC
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||
{createdAtLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/match-logs/${matchId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md border border-zinc-400 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-800 shadow-sm transition hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Show logs
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Bracketed timestamp only `[…]`; space after `]` is not part of the gray stamp. */
|
||||
const TIMESTAMP = /^(\[[^\]]+\])(\s*)([\s\S]*)$/;
|
||||
|
||||
const NORMAL = "text-zinc-50";
|
||||
|
||||
const SPAN_RULES: Array<{ re: RegExp; className: string }> = [
|
||||
{ re: /Red goal scored/, className: "font-semibold !text-rose-400" },
|
||||
{ re: /Blue goal scored/, className: "font-semibold !text-sky-400" },
|
||||
{
|
||||
re: /Dedicated match PATCH success: \d+/,
|
||||
className: "!text-emerald-400",
|
||||
},
|
||||
{ re: /Game started On Server/, className: "!text-amber-400" },
|
||||
{ re: /Starting server at port \d+/, className: "!text-amber-400" },
|
||||
{ re: /launching puck at/, className: "!text-cyan-400" },
|
||||
];
|
||||
|
||||
const CLIENT_TEAM = /(Client \d+ set team to )(Red|Blue)/;
|
||||
|
||||
type MatchInfo =
|
||||
| { index: number; len: number; kind: "span"; className: string }
|
||||
| {
|
||||
index: number;
|
||||
len: number;
|
||||
kind: "clientTeam";
|
||||
prefix: string;
|
||||
team: "Red" | "Blue";
|
||||
};
|
||||
|
||||
function pickEarlier(a: MatchInfo, b: MatchInfo): MatchInfo {
|
||||
if (a.index < b.index) return a;
|
||||
if (b.index < a.index) return b;
|
||||
return a.len >= b.len ? a : b;
|
||||
}
|
||||
|
||||
function findNextHighlight(sub: string): MatchInfo | null {
|
||||
let best: MatchInfo | null = null;
|
||||
|
||||
const cm = CLIENT_TEAM.exec(sub);
|
||||
if (cm) {
|
||||
best = {
|
||||
index: cm.index,
|
||||
len: cm[0].length,
|
||||
kind: "clientTeam",
|
||||
prefix: cm[1] ?? "",
|
||||
team: cm[2] === "Blue" ? "Blue" : "Red",
|
||||
};
|
||||
}
|
||||
|
||||
for (const { re, className } of SPAN_RULES) {
|
||||
const m = re.exec(sub);
|
||||
if (!m || m.index === undefined) continue;
|
||||
const cand: MatchInfo = {
|
||||
index: m.index,
|
||||
len: m[0].length,
|
||||
kind: "span",
|
||||
className,
|
||||
};
|
||||
best = best ? pickEarlier(best, cand) : cand;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function highlightRest(rest: string, lineKey: number): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let i = 0;
|
||||
let seg = 0;
|
||||
|
||||
while (i < rest.length) {
|
||||
const sub = rest.slice(i);
|
||||
const hit = findNextHighlight(sub);
|
||||
if (!hit) {
|
||||
out.push(
|
||||
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||
{rest.slice(i)}
|
||||
</span>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const abs = i + hit.index;
|
||||
if (abs > i) {
|
||||
out.push(
|
||||
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||
{rest.slice(i, abs)}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (hit.kind === "span") {
|
||||
out.push(
|
||||
<span key={`l${lineKey}-s${seg++}`} className={hit.className}>
|
||||
{rest.slice(abs, abs + hit.len)}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
const teamCls =
|
||||
hit.team === "Red"
|
||||
? "font-semibold !text-rose-400"
|
||||
: "font-semibold !text-sky-400";
|
||||
out.push(
|
||||
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||
{hit.prefix}
|
||||
</span>,
|
||||
);
|
||||
out.push(
|
||||
<span key={`l${lineKey}-s${seg++}`} className={teamCls}>
|
||||
{hit.team}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
i = abs + hit.len;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatLogLine(line: string, lineKey: number): ReactNode {
|
||||
const m = line.match(TIMESTAMP);
|
||||
if (!m) {
|
||||
return <span className={NORMAL}>{line}</span>;
|
||||
}
|
||||
const bracketTs = m[1] ?? "";
|
||||
const afterBracket = (m[2] ?? "") + (m[3] ?? "");
|
||||
return (
|
||||
<span>
|
||||
<span className="!text-zinc-500">{bracketTs}</span>
|
||||
{highlightRest(afterBracket, lineKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchLogColoredBody({ content }: { content: string }) {
|
||||
const lines = content.split("\n");
|
||||
return (
|
||||
<div
|
||||
className={`${NORMAL} font-mono text-[13px] leading-relaxed break-words selection:bg-emerald-900/50 selection:text-emerald-100`}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap">
|
||||
{formatLogLine(line, i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||
|
||||
type Props = {
|
||||
layout: "embedded" | "fullscreen";
|
||||
source: MatchmakerLogSource;
|
||||
processedHref: string;
|
||||
rawHref: string;
|
||||
content: string;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
/** If the user is within this many px of the bottom, treat as "following" the tail. */
|
||||
const TAIL_FOLLOW_PX = 80;
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const REFRESH_BTN =
|
||||
"shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function segClass(active: boolean) {
|
||||
return [
|
||||
"rounded border px-2.5 py-1 font-mono text-xs transition",
|
||||
active
|
||||
? "border-emerald-500/60 bg-emerald-950/40 text-emerald-200"
|
||||
: "border-zinc-600 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-white",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function fetchLogApi(source: MatchmakerLogSource): Promise<
|
||||
| { ok: true; content: string }
|
||||
| { ok: false; message: string }
|
||||
> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (source === "raw") params.set("mklog", "raw");
|
||||
params.set("_t", String(Date.now()));
|
||||
const res = await fetch(`/api/matchmaker-logs?${params.toString()}`, {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const rawText = await res.text();
|
||||
let data: { content?: string; error?: string };
|
||||
try {
|
||||
data = rawText ? (JSON.parse(rawText) as typeof data) : {};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Server returned non-JSON (lost session?). Refresh and sign in again.",
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
message: data.error ?? `Request failed (${res.status})`,
|
||||
};
|
||||
}
|
||||
return { ok: true, content: data.content ?? "" };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
message: e instanceof Error ? e.message : "Network error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function MatchmakerLogTerminal({
|
||||
layout,
|
||||
source,
|
||||
processedHref,
|
||||
rawHref,
|
||||
content,
|
||||
errorMessage,
|
||||
}: Props) {
|
||||
const filename =
|
||||
source === "processed" ? "history.log" : "matchmaker.log";
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const tailFollowRef = useRef(true);
|
||||
/** Avoid overlapping auto + manual fetches (helps dev Strict Mode / double clicks). */
|
||||
const fetchLockRef = useRef(false);
|
||||
|
||||
const [displayContent, setDisplayContent] = useState(content);
|
||||
const [displayError, setDisplayError] = useState(errorMessage);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
/** Bumped on every successful reload so identical file text still updates the UI (React skips re-render when setState equals previous string). */
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const runLoad = useCallback(async (manual: boolean) => {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.hidden &&
|
||||
!manual
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (fetchLockRef.current) return;
|
||||
|
||||
fetchLockRef.current = true;
|
||||
if (manual) {
|
||||
tailFollowRef.current = true;
|
||||
setRefreshing(true);
|
||||
}
|
||||
try {
|
||||
const result = await fetchLogApi(source);
|
||||
if (!result.ok) {
|
||||
setDisplayError(result.message);
|
||||
return;
|
||||
}
|
||||
setDisplayError(null);
|
||||
setDisplayContent(result.content);
|
||||
if (manual) {
|
||||
setReloadKey((k) => k + 1);
|
||||
setLastFetchedAtMs(Date.now());
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
if (manual) setRefreshing(false);
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await runLoad(true);
|
||||
}, [runLoad]);
|
||||
|
||||
const updateTailFollowFromScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
tailFollowRef.current = gap <= TAIL_FOLLOW_PX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayContent(content);
|
||||
setDisplayError(errorMessage);
|
||||
setReloadKey((k) => k + 1);
|
||||
}, [content, errorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
tailFollowRef.current = true;
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function tick() {
|
||||
if (cancelled) return;
|
||||
await runLoad(false);
|
||||
}
|
||||
|
||||
queueMicrotask(tick);
|
||||
const intervalId = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!document.hidden && !cancelled) void tick();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [source, runLoad]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !tailFollowRef.current) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [displayContent, displayError, reloadKey]);
|
||||
|
||||
const frameClass =
|
||||
layout === "fullscreen"
|
||||
? "flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg"
|
||||
: "flex h-[calc(100dvh-12rem)] max-h-[calc(100dvh-12rem)] min-h-[200px] shrink-0 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg";
|
||||
|
||||
return (
|
||||
<div className={frameClass}>
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||
<span className="text-emerald-400/90">matchmaker</span>
|
||||
<span className="text-zinc-500"> — </span>
|
||||
<span className="text-zinc-100">{filename}</span>
|
||||
</p>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500">
|
||||
Log
|
||||
</span>
|
||||
<Link
|
||||
href={processedHref}
|
||||
className={segClass(source === "processed")}
|
||||
scroll={false}
|
||||
>
|
||||
Processed
|
||||
</Link>
|
||||
<Link
|
||||
href={rawHref}
|
||||
className={segClass(source === "raw")}
|
||||
scroll={false}
|
||||
>
|
||||
Raw
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={REFRESH_BTN}
|
||||
disabled={refreshing}
|
||||
aria-busy={refreshing}
|
||||
aria-label="Refresh log from disk"
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
{refreshing ? "…" : "Refresh"}
|
||||
</button>
|
||||
{lastFetchedAtMs != null ? (
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
· manual{" "}
|
||||
{new Date(lastFetchedAtMs).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
· auto {POLL_INTERVAL_MS / 1000}s
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateTailFollowFromScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4"
|
||||
>
|
||||
{displayError ? (
|
||||
<p className="font-mono text-sm text-red-400">
|
||||
<span className="text-red-500/80">error:</span> {displayError}
|
||||
</p>
|
||||
) : displayContent === "" ? (
|
||||
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
|
||||
) : (
|
||||
<MatchLogColoredBody
|
||||
key={reloadKey}
|
||||
content={displayContent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
|
||||
<p className="font-mono text-[11px] text-zinc-500">
|
||||
<span className="text-emerald-600/80">⏎</span> UTF-8
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user