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>
);
}
+25
View File
@@ -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>
);
}
+323
View File
@@ -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">
0100 (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 03).");
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 03 (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>
);
}
+132
View File
@@ -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 &amp; 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>
);
}
+152
View File
@@ -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>
);
}
+278
View File
@@ -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>
);
}