init
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseStoredCoins } from "@/lib/coins-rc";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
|
||||
function normalizeValue(raw: FormDataEntryValue | null): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
function normalizeSettingValue(
|
||||
key: string,
|
||||
raw: FormDataEntryValue | null,
|
||||
): string | null {
|
||||
const s = raw == null ? "" : String(raw).trim();
|
||||
|
||||
if (key === "entry_fee") {
|
||||
if (s === "") return null;
|
||||
const coins = parseStoredCoins(s);
|
||||
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
||||
return String(coins);
|
||||
}
|
||||
|
||||
if (key === "bet_fee") {
|
||||
if (s === "") return null;
|
||||
const n = Math.round(Number(s));
|
||||
if (!Number.isFinite(n)) return "__invalid__";
|
||||
return String(Math.min(100, Math.max(0, n)));
|
||||
}
|
||||
|
||||
return normalizeValue(raw);
|
||||
}
|
||||
|
||||
export async function updateSetting(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const key = String(formData.get("key") ?? "").trim();
|
||||
if (!key) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const value = normalizeSettingValue(key, formData.get("value"));
|
||||
if (value === "__invalid__") {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("settings")
|
||||
.update({ value })
|
||||
.eq("key", key);
|
||||
|
||||
if (error) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
export async function insertSetting(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const key = String(formData.get("newKey") ?? "").trim();
|
||||
const value = normalizeValue(formData.get("newValue"));
|
||||
|
||||
if (!key) {
|
||||
redirect("/settings?addError=missing");
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect("/settings?addError=config");
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("settings").insert({ key, value });
|
||||
|
||||
if (error) {
|
||||
const code = error.code === "23505" ? "duplicate" : "other";
|
||||
redirect(`/settings?addError=${code}`);
|
||||
}
|
||||
|
||||
redirect("/settings");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
buildDashboardHref,
|
||||
type AdminDashboardTab,
|
||||
} from "@/lib/dashboard-search-url";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
|
||||
function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (s === "") return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function updateUserCcRc(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const tabRaw = String(formData.get("tab") ?? "");
|
||||
const tab: AdminDashboardTab =
|
||||
tabRaw === "matches"
|
||||
? "matches"
|
||||
: tabRaw === "players"
|
||||
? "players"
|
||||
: tabRaw === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||
const participant =
|
||||
participantRaw === "" ? null : participantRaw;
|
||||
|
||||
const base = { tab, highlightId, participantRaw: participant };
|
||||
|
||||
if (!Number.isInteger(userId) || userId < 1) {
|
||||
redirect(buildDashboardHref(base));
|
||||
}
|
||||
|
||||
const ccRaw = formData.get("cc");
|
||||
const rcRaw = formData.get("rc");
|
||||
const cc = parseScoreField(ccRaw);
|
||||
const rc = parseScoreField(rcRaw);
|
||||
const ccStr = ccRaw == null ? "" : String(ccRaw).trim();
|
||||
const rcStr = rcRaw == null ? "" : String(rcRaw).trim();
|
||||
if ((ccStr !== "" && cc === null) || (rcStr !== "" && rc === null)) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("users")
|
||||
.update({ cc, rc })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
redirect(buildDashboardHref(base));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
applySessionCookie,
|
||||
getSessionCookieSetOptions,
|
||||
} from "@/lib/auth/session-cookie";
|
||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||
|
||||
function invalidRedirect(request: Request) {
|
||||
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
let body: { username?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
username = String(body.username ?? "");
|
||||
password = String(body.password ?? "");
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
username = String(formData.get("username") ?? "");
|
||||
password = String(formData.get("password") ?? "");
|
||||
}
|
||||
|
||||
if (username !== "admin" || password !== "admin") {
|
||||
if (contentType.includes("application/json")) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
return invalidRedirect(request);
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(
|
||||
ADMIN_SESSION_COOKIE,
|
||||
"1",
|
||||
getSessionCookieSetOptions(request),
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||
applySessionCookie(res, request);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(
|
||||
ADMIN_SESSION_COOKIE,
|
||||
"",
|
||||
getSessionCookieClearOptions(request),
|
||||
);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
parseMatchIdParam,
|
||||
readMatchLogFile,
|
||||
} from "@/lib/match-logs-server";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ matchId: string }> },
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { matchId: raw } = await context.params;
|
||||
const matchId = parseMatchIdParam(raw);
|
||||
if (matchId == null) {
|
||||
return Response.json({ error: "Invalid match id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await readMatchLogFile(matchId);
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.message }, { status: result.status });
|
||||
}
|
||||
|
||||
return Response.json({ matchId, content: result.content });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
return Response.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = parseMatchmakerLogParam(searchParams.get("mklog"));
|
||||
|
||||
const result = await readMatchmakerLogFile(source);
|
||||
if (!result.ok) {
|
||||
return Response.json(
|
||||
{ error: result.message },
|
||||
{ status: result.status, headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ content: result.content },
|
||||
{ headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kick Kings Admin",
|
||||
description: "Admin panel for players and matches",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
type Props = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: Props) {
|
||||
const { error } = await searchParams;
|
||||
const invalid = error === "1";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Admin sign in
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Default:{" "}
|
||||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
admin
|
||||
</code>{" "}
|
||||
/{" "}
|
||||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
admin
|
||||
</code>
|
||||
</p>
|
||||
<form
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
className="mt-8 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{invalid ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
Invalid username or password.
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-zinc-900 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||
import {
|
||||
parseMatchIdParam,
|
||||
readMatchLogFile,
|
||||
} from "@/lib/match-logs-server";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ matchId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { matchId } = await params;
|
||||
return {
|
||||
title: `Match ${matchId} log · Kick Kings Admin`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MatchLogPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ matchId: string }>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { matchId: raw } = await params;
|
||||
const matchId = parseMatchIdParam(raw);
|
||||
if (matchId == null) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const result = await readMatchLogFile(matchId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
|
||||
<header className="flex shrink-0 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">soccar</span>
|
||||
<span className="text-zinc-500"> — </span>
|
||||
<span className="text-zinc-100">match_{matchId}.txt</span>
|
||||
</p>
|
||||
<Link
|
||||
href="/?tab=matches"
|
||||
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
|
||||
>
|
||||
Back to matches
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{!result.ok ? (
|
||||
<p className="font-mono text-sm text-red-400">
|
||||
<span className="text-red-500/80">error:</span> {result.message}
|
||||
</p>
|
||||
) : result.content === "" ? (
|
||||
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
|
||||
) : (
|
||||
<MatchLogColoredBody content={result.content} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Matchmaker log · Kick Kings Admin",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MatchmakerLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const sp = await searchParams;
|
||||
const raw =
|
||||
typeof sp.mklog === "string"
|
||||
? sp.mklog
|
||||
: Array.isArray(sp.mklog)
|
||||
? sp.mklog[0]
|
||||
: undefined;
|
||||
const source = parseMatchmakerLogParam(raw ?? null);
|
||||
const result = await readMatchmakerLogFile(source);
|
||||
|
||||
const qp = (s: typeof source) =>
|
||||
s === "raw" ? "?mklog=raw" : "";
|
||||
|
||||
const content = result.ok ? result.content : "";
|
||||
const errorMessage = result.ok ? null : result.message;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-[#0d1117] text-zinc-100">
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<MatchmakerLogTerminal
|
||||
layout="fullscreen"
|
||||
source={source}
|
||||
processedHref={`/matchmaker-logs${qp("processed")}`}
|
||||
rawHref={`/matchmaker-logs${qp("raw")}`}
|
||||
content={content}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
||||
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import type { AdminDashboardTab } from "@/lib/dashboard-search-url";
|
||||
import {
|
||||
parseMatchmakerLogParam,
|
||||
type MatchmakerLogSource,
|
||||
} from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
import type { DbMatch, DbUser } from "@/types/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function firstSearchParam(
|
||||
v: string | string[] | undefined,
|
||||
): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === "string") return v;
|
||||
return v[0] ?? null;
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
tab?: string | string[];
|
||||
highlight?: string | string[];
|
||||
participant?: string | string[];
|
||||
edit?: string | string[];
|
||||
saveError?: string | string[];
|
||||
mklog?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const tabParam = firstSearchParam(sp.tab);
|
||||
const tab: AdminDashboardTab =
|
||||
tabParam === "matches"
|
||||
? "matches"
|
||||
: tabParam === "players"
|
||||
? "players"
|
||||
: tabParam === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
const highlightId = firstSearchParam(sp.highlight);
|
||||
const participantRaw = firstSearchParam(sp.participant);
|
||||
const editRaw = firstSearchParam(sp.edit);
|
||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||
const editIdNum =
|
||||
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
let users: DbUser[] = [];
|
||||
let matches: DbMatch[] = [];
|
||||
let configError: string | null = null;
|
||||
let usersError: string | null = null;
|
||||
let matchesError: string | null = null;
|
||||
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||
null;
|
||||
|
||||
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||
let matchmakerContent = "";
|
||||
let matchmakerError: string | null = null;
|
||||
if (tab === "matchmaker") {
|
||||
const mkRaw = firstSearchParam(sp.mklog);
|
||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
||||
const mmRes = await readMatchmakerLogFile(matchmakerSource);
|
||||
if (mmRes.ok) {
|
||||
matchmakerContent = mmRes.content;
|
||||
} else {
|
||||
matchmakerError = mmRes.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
configError =
|
||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||
} else {
|
||||
const usersRes = await supabase
|
||||
.from("users")
|
||||
.select("id, created_at, username, cc, rc, last_logged_at")
|
||||
.order("id", { ascending: false })
|
||||
.limit(500);
|
||||
|
||||
if (usersRes.error) {
|
||||
usersError = usersRes.error.message;
|
||||
} else {
|
||||
users = (usersRes.data ?? []) as DbUser[];
|
||||
}
|
||||
|
||||
const matchesRes = await supabase
|
||||
.from("matches")
|
||||
.select("*")
|
||||
.order("id", { ascending: false })
|
||||
.limit(500);
|
||||
|
||||
if (matchesRes.error) {
|
||||
matchesError = matchesRes.error.message;
|
||||
} else {
|
||||
matches = (matchesRes.data ?? []) as DbMatch[];
|
||||
}
|
||||
|
||||
statsBundle = await loadDashboardStatsBundle(supabase);
|
||||
}
|
||||
|
||||
let editUser: DbUser | null = null;
|
||||
if (Number.isInteger(editIdNum) && editIdNum >= 1) {
|
||||
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
<AdminHeader />
|
||||
{configError ? (
|
||||
<div className="px-6 pt-8">
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{configError}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminDashboard
|
||||
users={users}
|
||||
matches={matches}
|
||||
usersError={usersError}
|
||||
matchesError={matchesError}
|
||||
statsBundle={statsBundle}
|
||||
tab={tab}
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
matchmakerSource={matchmakerSource}
|
||||
matchmakerContent={matchmakerContent}
|
||||
matchmakerError={matchmakerError}
|
||||
/>
|
||||
{editUser ? (
|
||||
<EditUserCcRcOverlay
|
||||
user={editUser}
|
||||
tab={tab}
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
saveError={saveError}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import type { DbSetting } from "@/types/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Settings · Kick Kings Admin",
|
||||
};
|
||||
}
|
||||
|
||||
function firstSearchParam(
|
||||
v: string | string[] | undefined,
|
||||
): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === "string") return v;
|
||||
return v[0] ?? null;
|
||||
}
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
saveError?: string | string[];
|
||||
addError?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const sp = await searchParams;
|
||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||
const addError = firstSearchParam(sp.addError);
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
let rows: DbSetting[] = [];
|
||||
let configError: string | null = null;
|
||||
let loadError: string | null = null;
|
||||
|
||||
if (!supabase) {
|
||||
configError =
|
||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||
} else {
|
||||
const res = await supabase
|
||||
.from("settings")
|
||||
.select("key, value")
|
||||
.order("key");
|
||||
|
||||
if (res.error) {
|
||||
loadError = res.error.message;
|
||||
} else {
|
||||
rows = (res.data ?? []) as DbSetting[];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
<AdminHeader />
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto mb-8 max-w-[900px]">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Global settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Key/value rows in the{" "}
|
||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||
settings
|
||||
</span>{" "}
|
||||
table. Values are stored as text.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{configError ? (
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{configError}
|
||||
</div>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdminSettingsEditor
|
||||
rows={rows}
|
||||
saveError={saveError}
|
||||
addError={addError}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { NextResponse } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
|
||||
|
||||
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
||||
function isHttpsRequest(request: Request): boolean {
|
||||
try {
|
||||
if (new URL(request.url).protocol === "https:") return true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const xf = request.headers.get("x-forwarded-proto");
|
||||
if (xf === "https") return true;
|
||||
if (xf === "http") return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getSessionCookieSetOptions(request: Request) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: ADMIN_SESSION_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSessionCookieClearOptions(request: Request) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach session cookie to a response (works reliably with redirects). */
|
||||
export function applySessionCookie(res: NextResponse, request: Request) {
|
||||
res.cookies.set(
|
||||
ADMIN_SESSION_COOKIE,
|
||||
"1",
|
||||
getSessionCookieSetOptions(request),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** HttpOnly cookie name for admin session (set after successful login). */
|
||||
export const ADMIN_SESSION_COOKIE = "kickkings_admin_session";
|
||||
|
||||
/** Session cookie max-age in seconds (30 days). */
|
||||
export const ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* RC ↔ coins mapping for `entry_fee` storage.
|
||||
*
|
||||
* RC = floor(coins / 4) + (coins mod 4) / 10
|
||||
* coins = floor(RC) * 4 + first decimal digit of RC (0–3)
|
||||
*/
|
||||
|
||||
export function parseStoredCoins(raw: string | null): number {
|
||||
if (raw == null || String(raw).trim() === "") return 0;
|
||||
const n = Number(String(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export function coinsToRc(coins: number): number {
|
||||
const c = Math.max(0, Math.floor(coins));
|
||||
return Math.floor(c / 4) + (c % 4) / 10;
|
||||
}
|
||||
|
||||
/** Single-decimal RC only; tenths digit must be 0–3. */
|
||||
export function rcToCoins(rc: number): number | null {
|
||||
if (!Number.isFinite(rc) || rc < 0) return null;
|
||||
const whole = Math.floor(rc + 1e-9);
|
||||
const frac = rc - whole;
|
||||
const scaled = frac * 10;
|
||||
const tenths = Math.round(scaled);
|
||||
if (Math.abs(scaled - tenths) > 0.01) return null;
|
||||
if (tenths < 0 || tenths > 3) return null;
|
||||
return whole * 4 + tenths;
|
||||
}
|
||||
|
||||
export function formatRcLabelFromCoins(coins: number): string {
|
||||
return `${coinsToRc(Math.max(0, Math.floor(coins))).toFixed(1)} RC`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||
|
||||
export type AdminDashboardTab =
|
||||
| "dashboard"
|
||||
| "players"
|
||||
| "matches"
|
||||
| "matchmaker";
|
||||
|
||||
export type DashboardUrlQuery = {
|
||||
tab: AdminDashboardTab;
|
||||
highlightId: string | null;
|
||||
participantRaw: string | null;
|
||||
editId?: number | null;
|
||||
saveError?: boolean;
|
||||
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||
matchmakerSource?: MatchmakerLogSource | null;
|
||||
};
|
||||
|
||||
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
|
||||
export function buildDashboardHref(q: DashboardUrlQuery): string {
|
||||
const p = new URLSearchParams();
|
||||
if (q.tab === "matches") p.set("tab", "matches");
|
||||
else if (q.tab === "players") p.set("tab", "players");
|
||||
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
||||
if (q.highlightId) p.set("highlight", q.highlightId);
|
||||
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
||||
p.set("mklog", "raw");
|
||||
}
|
||||
if (q.editId != null && Number.isFinite(q.editId)) {
|
||||
p.set("edit", String(q.editId));
|
||||
}
|
||||
if (q.saveError) p.set("saveError", "1");
|
||||
const s = p.toString();
|
||||
return s ? `/?${s}` : "/";
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export type DashboardStatsSnapshot = {
|
||||
activePlayersToday: number;
|
||||
activePlayersLastWeek: number;
|
||||
activePlayersLastMonth: number;
|
||||
matchesToday: number;
|
||||
matchesLastWeek: number;
|
||||
matchesLastMonth: number;
|
||||
totalUsers: number;
|
||||
totalMatches: number;
|
||||
};
|
||||
|
||||
export type LeaderboardRow = {
|
||||
userId: number;
|
||||
username: string | null;
|
||||
totalPrizeCc: number;
|
||||
};
|
||||
|
||||
export type DashboardStatsBundle = {
|
||||
stats: DashboardStatsSnapshot | null;
|
||||
leaderboard: LeaderboardRow[];
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/** Start of the current calendar day in UTC (matches admin timestamps shown as UTC). */
|
||||
function utcStartOfTodayIso(): string {
|
||||
const now = new Date();
|
||||
const start = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
return start.toISOString();
|
||||
}
|
||||
|
||||
function isoRollingDaysAgo(days: number): string {
|
||||
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function pickCount(
|
||||
res: { count: number | null; error: { message: string } | null },
|
||||
label: string,
|
||||
): { value: number | null; err: string | null } {
|
||||
if (res.error) {
|
||||
return { value: null, err: `${label}: ${res.error.message}` };
|
||||
}
|
||||
return { value: res.count ?? 0, err: null };
|
||||
}
|
||||
|
||||
async function fetchTopWinnersByPrizeCc(
|
||||
supabase: SupabaseClient,
|
||||
): Promise<{ rows: LeaderboardRow[]; error: string | null }> {
|
||||
const winnings = new Map<number, number>();
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
for (;;) {
|
||||
const { data, error } = await supabase
|
||||
.from("matches")
|
||||
.select("winner_id, prize_cc")
|
||||
.not("winner_id", "is", null)
|
||||
.range(offset, offset + pageSize - 1);
|
||||
|
||||
if (error) {
|
||||
return { rows: [], error: error.message };
|
||||
}
|
||||
|
||||
const batch = data ?? [];
|
||||
for (const row of batch) {
|
||||
const wid = row.winner_id as number;
|
||||
const raw = row.prize_cc;
|
||||
const prize =
|
||||
typeof raw === "number"
|
||||
? raw
|
||||
: raw != null
|
||||
? Number(raw)
|
||||
: 0;
|
||||
const add = Number.isFinite(prize) ? prize : 0;
|
||||
winnings.set(wid, (winnings.get(wid) ?? 0) + add);
|
||||
}
|
||||
|
||||
if (batch.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
const topPairs = [...winnings.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
if (topPairs.length === 0) {
|
||||
return { rows: [], error: null };
|
||||
}
|
||||
|
||||
const ids = topPairs.map(([id]) => id);
|
||||
const { data: userRows, error: userErr } = await supabase
|
||||
.from("users")
|
||||
.select("id, username")
|
||||
.in("id", ids);
|
||||
|
||||
if (userErr) {
|
||||
return { rows: [], error: userErr.message };
|
||||
}
|
||||
|
||||
const nameById = new Map<number, string | null>();
|
||||
for (const u of userRows ?? []) {
|
||||
nameById.set(u.id as number, (u.username as string | null) ?? null);
|
||||
}
|
||||
|
||||
const rows: LeaderboardRow[] = topPairs.map(([userId, totalPrizeCc]) => ({
|
||||
userId,
|
||||
username: nameById.get(userId) ?? null,
|
||||
totalPrizeCc,
|
||||
}));
|
||||
|
||||
return { rows, error: null };
|
||||
}
|
||||
|
||||
/** Aggregates counts and leaderboard for the admin overview tab. Server-only. */
|
||||
export async function loadDashboardStatsBundle(
|
||||
supabase: SupabaseClient,
|
||||
): Promise<DashboardStatsBundle> {
|
||||
const startToday = utcStartOfTodayIso();
|
||||
const weekAgo = isoRollingDaysAgo(7);
|
||||
const monthAgo = isoRollingDaysAgo(30);
|
||||
|
||||
const [
|
||||
totalUsersRes,
|
||||
totalMatchesRes,
|
||||
activeTodayRes,
|
||||
activeWeekRes,
|
||||
activeMonthRes,
|
||||
matchesTodayRes,
|
||||
matchesWeekRes,
|
||||
matchesMonthRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("users").select("id", { count: "exact", head: true }),
|
||||
supabase.from("matches").select("id", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("users")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("last_logged_at", startToday),
|
||||
supabase
|
||||
.from("users")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("last_logged_at", weekAgo),
|
||||
supabase
|
||||
.from("users")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("last_logged_at", monthAgo),
|
||||
supabase
|
||||
.from("matches")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("created_at", startToday),
|
||||
supabase
|
||||
.from("matches")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("created_at", weekAgo),
|
||||
supabase
|
||||
.from("matches")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("created_at", monthAgo),
|
||||
]);
|
||||
|
||||
const parts = [
|
||||
pickCount(totalUsersRes, "total users"),
|
||||
pickCount(totalMatchesRes, "total matches"),
|
||||
pickCount(activeTodayRes, "active players today"),
|
||||
pickCount(activeWeekRes, "active players (7d)"),
|
||||
pickCount(activeMonthRes, "active players (30d)"),
|
||||
pickCount(matchesTodayRes, "matches today"),
|
||||
pickCount(matchesWeekRes, "matches (7d)"),
|
||||
pickCount(matchesMonthRes, "matches (30d)"),
|
||||
];
|
||||
|
||||
const errs = parts.map((p) => p.err).filter(Boolean) as string[];
|
||||
const values = parts.map((p) => p.value);
|
||||
|
||||
const allOk = values.every((v) => v !== null);
|
||||
const stats: DashboardStatsSnapshot | null = allOk
|
||||
? {
|
||||
totalUsers: values[0]!,
|
||||
totalMatches: values[1]!,
|
||||
activePlayersToday: values[2]!,
|
||||
activePlayersLastWeek: values[3]!,
|
||||
activePlayersLastMonth: values[4]!,
|
||||
matchesToday: values[5]!,
|
||||
matchesLastWeek: values[6]!,
|
||||
matchesLastMonth: values[7]!,
|
||||
}
|
||||
: null;
|
||||
|
||||
const { rows: leaderboard, error: lbErr } =
|
||||
await fetchTopWinnersByPrizeCc(supabase);
|
||||
|
||||
const errorMessages = [...errs, ...(lbErr ? [`leaderboard: ${lbErr}`] : [])];
|
||||
const error = errorMessages.length > 0 ? errorMessages.join(" · ") : null;
|
||||
|
||||
return { stats, leaderboard, error };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function parseMatchIdParam(raw: string): number | null {
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n < 1) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
export type ReadMatchLogResult =
|
||||
| { ok: true; content: string }
|
||||
| {
|
||||
ok: false;
|
||||
status: 400 | 401 | 404 | 413 | 500 | 503;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function readMatchLogFile(
|
||||
matchId: number,
|
||||
): Promise<ReadMatchLogResult> {
|
||||
const dirRaw = process.env.MATCH_LOGS_DIR?.trim();
|
||||
if (!dirRaw) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
message:
|
||||
"Match logs directory is not configured (set MATCH_LOGS_DIR on the server).",
|
||||
};
|
||||
}
|
||||
|
||||
const base = path.resolve(dirRaw);
|
||||
const filePath = path.resolve(base, `${matchId}.txt`);
|
||||
if (!filePath.startsWith(base + path.sep)) {
|
||||
return { ok: false, status: 500, message: "Invalid log path." };
|
||||
}
|
||||
|
||||
let st: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
st = await fs.stat(filePath);
|
||||
} catch (e: unknown) {
|
||||
const code =
|
||||
e && typeof e === "object" && "code" in e
|
||||
? String((e as { code: unknown }).code)
|
||||
: "";
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
message: "No log file for this match.",
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 500, message: "Could not access log file." };
|
||||
}
|
||||
|
||||
if (!st.isFile()) {
|
||||
return { ok: false, status: 404, message: "Log path is not a file." };
|
||||
}
|
||||
|
||||
if (st.size > MAX_LOG_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 413,
|
||||
message: `Log file is too large (max ${MAX_LOG_BYTES} bytes).`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return { ok: true, content: buf.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, status: 500, message: "Could not read log file." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type MatchmakerLogSource = "processed" | "raw";
|
||||
|
||||
export function parseMatchmakerLogParam(
|
||||
raw: string | null | undefined,
|
||||
): MatchmakerLogSource {
|
||||
if (raw === "raw") return "raw";
|
||||
return "processed";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||
|
||||
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const FILENAMES = {
|
||||
processed: "history.log",
|
||||
raw: "matchmaker.log",
|
||||
} as const satisfies Record<MatchmakerLogSource, string>;
|
||||
|
||||
export type ReadMatchmakerLogResult =
|
||||
| { ok: true; content: string; filename: string; source: MatchmakerLogSource }
|
||||
| {
|
||||
ok: false;
|
||||
status: 404 | 413 | 500 | 503;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function readMatchmakerLogFile(
|
||||
source: MatchmakerLogSource,
|
||||
): Promise<ReadMatchmakerLogResult> {
|
||||
const dirRaw = process.env.MATCHMAKER_LOGS_DIR?.trim();
|
||||
if (!dirRaw) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
message:
|
||||
"Matchmaker logs directory is not configured (set MATCHMAKER_LOGS_DIR on the server).",
|
||||
};
|
||||
}
|
||||
|
||||
const base = path.resolve(dirRaw);
|
||||
const name = FILENAMES[source];
|
||||
const filePath = path.resolve(base, name);
|
||||
if (!filePath.startsWith(base + path.sep)) {
|
||||
return { ok: false, status: 500, message: "Invalid log path." };
|
||||
}
|
||||
|
||||
let st: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
st = await fs.stat(filePath);
|
||||
} catch (e: unknown) {
|
||||
const code =
|
||||
e && typeof e === "object" && "code" in e
|
||||
? String((e as { code: unknown }).code)
|
||||
: "";
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
message: `No matchmaker log file (${name}).`,
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 500, message: "Could not access log file." };
|
||||
}
|
||||
|
||||
if (!st.isFile()) {
|
||||
return { ok: false, status: 404, message: "Log path is not a file." };
|
||||
}
|
||||
|
||||
if (st.size > MAX_LOG_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 413,
|
||||
message: `Log file is too large (max ${MAX_LOG_BYTES} bytes).`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return {
|
||||
ok: true,
|
||||
content: buf.toString("utf8"),
|
||||
filename: name,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, status: 500, message: "Could not read log file." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
function stripTrailingSlashes(s: string): string {
|
||||
return s.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Strip accidental CRLF / extra lines (e.g. Windows .env) — `\r` mid-URL becomes `...comlogin`. */
|
||||
function firstUrlLine(s: string): string {
|
||||
return s.split("\r")[0]?.split("\n")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* `playpoolstudios.com` + `login` (no `/`) often becomes host `...comlogin` in env or proxy headers.
|
||||
*/
|
||||
function normalizeMergedComLoginHost(hostWithOptionalPort: string): string {
|
||||
const raw = firstUrlLine(hostWithOptionalPort).trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
let hostPart = raw;
|
||||
let portPart = "";
|
||||
if (raw.startsWith("[")) {
|
||||
const end = raw.indexOf("]:");
|
||||
if (end !== -1) {
|
||||
hostPart = raw.slice(0, end + 1);
|
||||
portPart = raw.slice(end + 1);
|
||||
}
|
||||
} else {
|
||||
const idx = raw.lastIndexOf(":");
|
||||
if (idx > 0 && /^\d+$/.test(raw.slice(idx + 1))) {
|
||||
hostPart = raw.slice(0, idx);
|
||||
portPart = raw.slice(idx);
|
||||
}
|
||||
}
|
||||
|
||||
const fixed = hostPart.replace(/\.comlogin$/i, ".com");
|
||||
return fixed + portPart;
|
||||
}
|
||||
|
||||
function parseForwardedHeader(value: string | null): {
|
||||
host?: string;
|
||||
proto?: string;
|
||||
} {
|
||||
if (!value) return {};
|
||||
const segment = value.split(",")[0]?.trim() ?? "";
|
||||
const out: { host?: string; proto?: string } = {};
|
||||
for (const part of segment.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = part.slice(0, eq).trim().toLowerCase();
|
||||
let val = part.slice(eq + 1).trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
if (key === "host") out.host = firstUrlLine(val);
|
||||
if (key === "proto") out.proto = firstUrlLine(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public origin for absolute redirects when the app sits behind a reverse proxy.
|
||||
* Override with APP_ORIGIN (e.g. https://kickkings.playpoolstudios.com) if headers are absent.
|
||||
*/
|
||||
export function getRequestOrigin(request: Request): string {
|
||||
const explicit = firstUrlLine(process.env.APP_ORIGIN ?? "");
|
||||
if (explicit) {
|
||||
const normalized = /^[a-z][a-z0-9+.-]*:/i.test(explicit)
|
||||
? explicit
|
||||
: `https://${explicit}`;
|
||||
try {
|
||||
const u = new URL(stripTrailingSlashes(normalized));
|
||||
u.hostname = normalizeMergedComLoginHost(u.hostname);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return stripTrailingSlashes(explicit);
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const fwd = parseForwardedHeader(request.headers.get("forwarded"));
|
||||
|
||||
const xfHost = request.headers.get("x-forwarded-host");
|
||||
const hostFromHeaders = fwd.host ?? xfHost?.split(",")[0]?.trim() ?? "";
|
||||
const hostRaw = normalizeMergedComLoginHost(
|
||||
firstUrlLine(hostFromHeaders) || url.host,
|
||||
);
|
||||
|
||||
const xfProto = request.headers.get("x-forwarded-proto");
|
||||
const protoRaw = firstUrlLine(
|
||||
fwd.proto ?? xfProto?.split(",")[0]?.trim() ?? (url.protocol === "https:" ? "https" : "http"),
|
||||
);
|
||||
const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http";
|
||||
|
||||
try {
|
||||
return new URL(`${proto}://${hostRaw}`).origin;
|
||||
} catch {
|
||||
url.hostname = normalizeMergedComLoginHost(url.hostname);
|
||||
return url.origin;
|
||||
}
|
||||
}
|
||||
|
||||
export function publicRequestUrl(request: Request, pathname: string): URL {
|
||||
const origin = getRequestOrigin(request);
|
||||
const base = new URL(origin);
|
||||
const path = pathname.trim();
|
||||
const p = path.startsWith("/") ? path : `/${path}`;
|
||||
return new URL(p, base);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
/**
|
||||
* Server-only Supabase client using the service role key so the admin UI can
|
||||
* read all rows regardless of RLS. Never import this from client components.
|
||||
*/
|
||||
export function createAdminSupabase(): SupabaseClient | null {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!url || !key) {
|
||||
return null;
|
||||
}
|
||||
return createClient(url, key, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||
|
||||
function hasValidSession(request: NextRequest): boolean {
|
||||
return request.cookies.get(ADMIN_SESSION_COOKIE)?.value === "1";
|
||||
}
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isLogin = pathname === "/login";
|
||||
const isAuthApi = pathname.startsWith("/api/auth/");
|
||||
|
||||
if (isAuthApi) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Let route handlers return JSON (401, etc.). A redirect to /login breaks `fetch` + `res.json()` in the admin UI.
|
||||
if (!hasValidSession(request) && pathname.startsWith("/api/")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (isLogin) {
|
||||
if (hasValidSession(request)) {
|
||||
return NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!hasValidSession(request)) {
|
||||
return NextResponse.redirect(publicRequestUrl(request, "/login"));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Mirrors `public.users` (see schemas/users.md). */
|
||||
export type DbUser = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
cc: number | null;
|
||||
rc: number | null;
|
||||
last_logged_at: string | null;
|
||||
};
|
||||
|
||||
/** Mirrors `public.matches` (see schemas/matches.md). */
|
||||
export type DbMatch = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
/** BIGINT may deserialize as string over JSON. */
|
||||
user_red: number | string | null;
|
||||
user_blue: number | string | null;
|
||||
entry_fee: number | null;
|
||||
prize_cc: number | null;
|
||||
red_joined_at: string | null;
|
||||
blue_joined_at: string | null;
|
||||
status: number | null;
|
||||
/** BIGINT may deserialize as string over JSON. */
|
||||
winner_id: number | string | null;
|
||||
};
|
||||
|
||||
/** Mirrors `public.settings` (key/value config rows). */
|
||||
export type DbSetting = {
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user