This commit is contained in:
NextJS
2026-05-05 20:02:40 +00:00
commit 0f6e979aac
51 changed files with 10161 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6708
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "kickkings_admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 2613",
"build": "next build",
"start": "next start -p 2613",
"lint": "eslint"
},
"dependencies": {
"@supabase/supabase-js": "^2.102.1",
"next": "^16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+16
View File
@@ -0,0 +1,16 @@
create table public.matches (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
user_red bigint null,
user_blue bigint null,
entry_fee bigint null default '34'::bigint,
prize_cc bigint null default '100'::bigint,
red_joined_at timestamp with time zone null,
blue_joined_at timestamp with time zone null,
status smallint null default '0'::smallint,
winner_id bigint null,
constraint matches_pkey primary key (id),
constraint matches_user_blue_fkey foreign KEY (user_blue) references users (id),
constraint matches_user_red_fkey foreign KEY (user_red) references users (id),
constraint matches_winner_id_fkey foreign KEY (winner_id) references users (id)
) TABLESPACE pg_default;
+6
View File
@@ -0,0 +1,6 @@
```sql
create table public.settings (
key text not null primary key,
value text null
);
```
+10
View File
@@ -0,0 +1,10 @@
create table public.users (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
username text null,
password text null,
cc real null default '0'::real,
rc real null default '0'::real,
last_logged_at timestamp with time zone null default now(),
constraint users_pkey primary key (id)
) TABLESPACE pg_default;
+97
View File
@@ -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");
}
+91
View File
@@ -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));
}
+54
View File
@@ -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;
}
+13
View File
@@ -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;
}
+29
View File
@@ -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 });
}
+32
View File
@@ -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

+26
View File
@@ -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;
}
+33
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+594
View File
@@ -0,0 +1,594 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type { DbMatch, DbUser } from "@/types/database";
/** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
if (winnerId == null) return false;
if (typeof winnerId === "number") {
return Number.isInteger(winnerId) && winnerId > 0;
}
if (typeof winnerId === "string") {
const t = winnerId.trim();
if (t === "" || !/^-?\d+$/.test(t)) return false;
const n = Number(t);
return Number.isFinite(n) && n > 0;
}
return false;
}
function participantInMatch(m: DbMatch, participantId: number): boolean {
const red = m.user_red;
const blue = m.user_blue;
if (red != null && Number(red) === participantId) return true;
if (blue != null && Number(blue) === participantId) return true;
return false;
}
function coalesceUserId(v: number | string | null): number | null {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
/** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */
function formatTs(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
} catch {
return value;
}
}
type Props = {
users: DbUser[];
matches: DbMatch[];
usersError: string | null;
matchesError: string | null;
statsBundle: DashboardStatsBundle | null;
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
tab: AdminDashboardTab;
highlightId: string | null;
participantRaw: string | null;
matchmakerSource: MatchmakerLogSource;
matchmakerContent: string;
matchmakerError: string | null;
};
function StatCard({
title,
value,
}: {
title: string;
value: number | null;
}) {
const display =
value == null ? "—" : value.toLocaleString("en-US");
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-col items-center text-center">
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
{title}
</p>
<p className="mt-1.5 text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
{display}
</p>
</div>
</div>
);
}
export function AdminDashboard({
users,
matches,
usersError,
matchesError,
statsBundle,
tab,
highlightId,
participantRaw,
matchmakerSource,
matchmakerContent,
matchmakerError,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const userById = useMemo(() => {
const m = new Map<number, DbUser>();
for (const u of users) {
m.set(u.id, u);
}
return m;
}, [users]);
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) {
m.set(u.id, u.username);
}
return m;
}, [users]);
const participantId = useMemo(() => {
if (!participantRaw) return null;
const n = Number(participantRaw);
return Number.isFinite(n) ? n : null;
}, [participantRaw]);
const filteredMatches = useMemo(() => {
if (participantId == null) return matches;
return matches.filter((m) => participantInMatch(m, participantId));
}, [matches, participantId]);
const visibleMatches = useMemo(() => {
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
const el = document.getElementById(`player-row-${highlightId}`);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [tab, highlightId]);
const tabClass = (active: boolean) =>
[
"inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition",
active
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700",
].join(" ");
function editHref(u: DbUser): string {
return buildDashboardHref({
tab,
highlightId,
participantRaw,
editId: u.id,
});
}
const totalUsersLabel =
statsBundle?.stats?.totalUsers ?? users.length;
const totalMatchesLabel =
statsBundle?.stats?.totalMatches ?? matches.length;
const statusLabel = (status: number | null) => {
if (status === 2) return "Final";
if (status === 1) return "Live";
if (status === 0) return "Waiting";
return status == null ? "Unknown" : `Status ${status}`;
};
return (
<main className="flex-1 space-y-6 px-6 py-8">
<div className="flex flex-wrap gap-2 border-b border-zinc-200 pb-4 dark:border-zinc-800">
<Link
href={buildDashboardHref({
tab: "dashboard",
highlightId,
participantRaw,
})}
className={tabClass(tab === "dashboard")}
scroll={false}
>
Overview
</Link>
<Link
href={buildDashboardHref({
tab: "players",
highlightId,
participantRaw,
})}
className={tabClass(tab === "players")}
scroll={false}
>
Players (
{totalUsersLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${users.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matches")}
scroll={false}
>
Matches (
{totalMatchesLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${matches.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matchmaker")}
scroll={false}
>
Matchmaker
</Link>
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
</div>
<div className="mx-auto w-full max-w-[1400px]">
{tab === "dashboard" ? (
<section className="space-y-8">
{statsBundle?.error ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
{statsBundle.error}
</div>
) : null}
<div className="grid gap-8 xl:grid-cols-2">
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Active players
</h2>
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
<StatCard
title="Active players today"
value={statsBundle?.stats?.activePlayersToday ?? null}
/>
<StatCard
title="Active players (last 7 days)"
value={statsBundle?.stats?.activePlayersLastWeek ?? null}
/>
<StatCard
title="Active players (last 30 days)"
value={statsBundle?.stats?.activePlayersLastMonth ?? null}
/>
</div>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Matches
</h2>
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
<StatCard
title="Matches today"
value={statsBundle?.stats?.matchesToday ?? null}
/>
<StatCard
title="Matches (last 7 days)"
value={statsBundle?.stats?.matchesLastWeek ?? null}
/>
<StatCard
title="Matches (last 30 days)"
value={statsBundle?.stats?.matchesLastMonth ?? null}
/>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<StatCard
title="Total users"
value={statsBundle?.stats?.totalUsers ?? null}
/>
<StatCard
title="Total matches"
value={statsBundle?.stats?.totalMatches ?? null}
/>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Top players by match winnings
</h2>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">#</th>
<th className="px-4 py-3 font-medium">Player</th>
<th className="px-4 py-3 font-medium text-right">
Total prize CC
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={3}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
</td>
</tr>
) : (
(statsBundle?.leaderboard ?? []).map((row, i) => (
<tr
key={row.userId}
className="text-zinc-800 dark:text-zinc-200"
>
<td className="px-4 py-2 font-mono text-xs text-zinc-500">
{i + 1}
</td>
<td className="px-4 py-2">
<Link
href={`/?tab=players&highlight=${row.userId}`}
className="font-mono text-xs text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
{row.userId}
{row.username != null && row.username !== "" ? (
<span className="text-zinc-700 dark:text-zinc-300">
{" "}
({row.username})
</span>
) : (
<span className="text-zinc-500"> ()</span>
)}
</Link>
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.totalPrizeCc.toLocaleString("en-US")}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</section>
) : tab === "players" ? (
<section>
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
</td>
</tr>
) : (
users.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
return (
<tr
key={u.id}
id={`player-row-${u.id}`}
className={
isHi
? "bg-amber-100/90 ring-2 ring-inset ring-amber-400/80 dark:bg-amber-950/50 dark:ring-amber-500/60"
: "text-zinc-800 dark:text-zinc-200"
}
>
<td className="px-4 py-2 font-mono text-xs">{u.id}</td>
<td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.created_at)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
<Link
href={`/?tab=matches&participant=${u.id}`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
scroll={false}
>
Show matches
</Link>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
</section>
) : tab === "matchmaker" ? (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<p>
<span className="font-mono text-zinc-700 dark:text-zinc-300">
history.log
</span>{" "}
is the processed summary;{" "}
<span className="font-mono text-zinc-700 dark:text-zinc-300">
matchmaker.log
</span>{" "}
is raw output. Toggle below or{" "}
<Link
href={`/matchmaker-logs${matchmakerSource === "raw" ? "?mklog=raw" : ""}`}
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
target="_blank"
rel="noopener noreferrer"
>
open fullscreen
</Link>
.
</p>
</div>
<MatchmakerLogTerminal
layout="embedded"
source={matchmakerSource}
processedHref={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
rawHref={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
matchmakerSource: "raw",
})}
content={matchmakerContent}
errorMessage={matchmakerError}
/>
</section>
) : (
<section className="space-y-3">
{participantId != null ? (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
role="status"
>
<span>
Showing matches where{" "}
<span className="font-mono font-medium">
{participantId}
</span>
{usernameById.get(participantId) != null &&
usernameById.get(participantId) !== "" ? (
<span>
{" "}
({usernameById.get(participantId)})
</span>
) : null}{" "}
is the Red or Blue user (
{filteredMatches.length} of {matches.length})
</span>
<Link
href="/?tab=matches"
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
scroll={false}
>
Clear filter
</Link>
</div>
) : null}
{matchesError ? (
<p className="text-sm text-red-600 dark:text-red-400">
{matchesError}
</p>
) : (
<div className="grid gap-3">
<div className="relative z-10 mb-1 flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label
htmlFor="admin-hide-matches-no-winner"
className="inline-flex cursor-pointer items-center gap-2 text-zinc-700 dark:text-zinc-200"
>
<input
id="admin-hide-matches-no-winner"
type="checkbox"
checked={hideNoWinner}
onChange={(e) => setHideNoWinner(e.target.checked)}
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
/>
Hide matches with no winner
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {visibleMatches.length} of {filteredMatches.length}
</span>
</div>
{visibleMatches.length === 0 ? (
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
{filteredMatches.length === 0
? "No matches yet."
: hideNoWinner
? "No matches left after hiding no-winner matches."
: "No matches for this filter."}
</div>
) : (
visibleMatches.map((m) => {
const redId = coalesceUserId(m.user_red);
const blueId = coalesceUserId(m.user_blue);
const leftUser =
redId != null ? userById.get(redId) : null;
const rightUser =
blueId != null ? userById.get(blueId) : null;
return (
<MatchHistoryBattleCard
key={m.id}
matchId={m.id}
statusLabel={statusLabel(m.status)}
createdAtLabel={formatTs(m.created_at)}
entryFee={m.entry_fee}
prizeCc={m.prize_cc}
winnerId={m.winner_id}
left={{
id: redId,
username: leftUser?.username ?? null,
cc: leftUser?.cc ?? null,
rc: leftUser?.rc ?? null,
color: "red",
}}
right={{
id: blueId,
username: rightUser?.username ?? null,
cc: rightUser?.cc ?? null,
rc: rightUser?.rc ?? null,
color: "blue",
}}
/>
);
})
)}
</div>
)}
</section>
)}
</div>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
export function AdminHeader() {
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login";
}
return (
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
<div>
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard
</h1>
</div>
<button
type="button"
onClick={() => void logout()}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Log out
</button>
</header>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import {
insertSetting,
updateSetting,
} from "@/app/actions/settings-actions";
import {
coinsToRc,
parseStoredCoins,
rcToCoins,
} from "@/lib/coins-rc";
import type { DbSetting } from "@/types/database";
type Props = {
rows: DbSetting[];
saveError: boolean;
addError: string | null;
};
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`setting-value-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<input
id={`setting-value-${index}`}
name="value"
type="text"
defaultValue={row.value ?? ""}
autoComplete="off"
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
);
}
function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
const initial = Math.min(
100,
Math.max(0, Math.round(Number(row.value ?? 0))),
);
const [v, setV] = useState(initial);
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`bet-fee-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
0100 (saved as the number shown).
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<input
id={`bet-fee-${index}`}
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(Number(e.target.value))}
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
/>
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{v}
</span>
</div>
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
);
}
function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
const initialCoins = parseStoredCoins(row.value);
const initialRc = coinsToRc(initialCoins);
const [rcText, setRcText] = useState(() => initialRc.toFixed(1));
const [localError, setLocalError] = useState<string | null>(null);
const coinsPreview = useMemo(() => {
const t = rcText.trim();
if (t === "") return null;
return rcToCoins(Number(t));
}, [rcText]);
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError("Enter an RC value (one decimal place; tenths digit 03).");
return;
}
const rc = Number(trimmed);
const coins = rcToCoins(rc);
if (coins === null) {
e.preventDefault();
setLocalError(
"Enter a valid RC with one decimal place; the tenths digit must be 03 (e.g. 5.1).",
);
}
}}
>
<input type="hidden" name="key" value={row.key} />
<input
type="hidden"
name="value"
value={coinsPreview === null ? "" : String(coinsPreview)}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`entry-fee-rc-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
(edit as RC; stored as coins)
</span>
</label>
<input
id={`entry-fee-rc-${index}`}
type="text"
inputMode="decimal"
autoComplete="off"
value={rcText}
onChange={(e) => setRcText(e.target.value)}
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{localError ? (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
{localError}
</p>
) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
database.
</p>
) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Not a valid RC encoding yet fix the value to save.
</p>
) : null}
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
);
}
function SettingRow({ row, index }: { row: DbSetting; index: number }) {
if (row.key === "bet_fee") {
return <BetFeeRow row={row} index={index} />;
}
if (row.key === "entry_fee") {
return <EntryFeeRow row={row} index={index} />;
}
return <DefaultSettingRow row={row} index={index} />;
}
export function AdminSettingsEditor({
rows,
saveError,
addError,
}: Props) {
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
Could not save that row. Try again or check Supabase connectivity.
</div>
) : null}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing keys
</h2>
{rows.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No settings rows yet. Add one below.
</p>
) : (
<div className="space-y-3">
{rows.map((row, i) => (
<SettingRow key={row.key} row={row} index={i} />
))}
</div>
)}
</section>
<section className="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Add setting
</h2>
{addError === "duplicate" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
That key already exists. Edit it in the list above instead.
</p>
) : addError === "missing" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty key.
</p>
) : addError === "config" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Supabase admin client is not configured.
</p>
) : addError === "other" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Could not insert. Check constraints and retry.
</p>
) : null}
<form action={insertSetting} className="mt-4 flex flex-col gap-4">
<div>
<label
htmlFor="new-setting-key"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Key
</label>
<input
id="new-setting-key"
name="newKey"
type="text"
required
autoComplete="off"
placeholder="e.g. maintenance_message"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div>
<label
htmlFor="new-setting-value"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
id="new-setting-value"
name="newValue"
type="text"
autoComplete="off"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Add row
</button>
</div>
</form>
</section>
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
<Link
href="/"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
Back to dashboard
</Link>
</p>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import Link from "next/link";
import { updateUserCcRc } from "@/app/actions/update-user-cc-rc";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { DbUser } from "@/types/database";
type Props = {
user: DbUser;
tab: AdminDashboardTab;
highlightId: string | null;
participantRaw: string | null;
saveError: boolean;
};
export function EditUserCcRcOverlay({
user,
tab,
highlightId,
participantRaw,
saveError,
}: Props) {
const cancelHref = buildDashboardHref({
tab,
highlightId,
participantRaw,
});
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="edit-cc-rc-title"
>
<div className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
<h2
id="edit-cc-rc-title"
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
>
Edit CC &amp; RC
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Player{" "}
<span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">
{user.id}
</span>
{user.username ? <span> ({user.username})</span> : null}
</p>
{saveError ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">
Could not save. Check that CC and RC are valid numbers or empty.
</p>
) : null}
<form action={updateUserCcRc} className="mt-5 space-y-4">
<input type="hidden" name="userId" value={user.id} />
<input
type="hidden"
name="tab"
value={
tab === "matches"
? "matches"
: tab === "players"
? "players"
: "dashboard"
}
/>
<input
type="hidden"
name="highlightId"
value={highlightId ?? ""}
/>
<input
type="hidden"
name="participantRaw"
value={participantRaw ?? ""}
/>
<div>
<label
htmlFor={`edit-cc-${user.id}`}
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
CC
</label>
<input
id={`edit-cc-${user.id}`}
name="cc"
type="text"
inputMode="decimal"
defaultValue={user.cc == null ? "" : String(user.cc)}
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
autoComplete="off"
/>
</div>
<div>
<label
htmlFor={`edit-rc-${user.id}`}
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
RC
</label>
<input
id={`edit-rc-${user.id}`}
name="rc"
type="text"
inputMode="decimal"
defaultValue={user.rc == null ? "" : String(user.rc)}
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
autoComplete="off"
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Link
href={cancelHref}
scroll={false}
className="inline-flex items-center justify-center rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</Link>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
import Link from "next/link";
type PlayerSide = {
id: number | null;
username: string | null;
cc: number | null;
rc: number | null;
color: "red" | "blue";
};
type Props = {
matchId: number;
statusLabel: string;
createdAtLabel: string;
entryFee: number | null;
prizeCc: number | null;
left: PlayerSide;
right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null;
};
function idsMatch(
a: number | string | null | undefined,
b: number | string | null | undefined,
): boolean {
if (a == null || b == null) return false;
const na = typeof a === "number" ? a : Number(a);
const nb = typeof b === "number" ? b : Number(b);
return Number.isFinite(na) && Number.isFinite(nb) && na === nb;
}
function sideStyles(color: PlayerSide["color"]) {
if (color === "red") {
return {
panel:
"border-rose-500/60 bg-gradient-to-br from-rose-700/80 via-fuchsia-700/70 to-violet-700/80",
tag: "bg-rose-500/90 text-rose-50",
iconBg: "bg-rose-500/30 text-rose-100",
score: "text-rose-100",
};
}
return {
panel:
"border-sky-500/60 bg-gradient-to-br from-sky-700/80 via-indigo-700/70 to-violet-700/80",
tag: "bg-sky-500/90 text-sky-50",
iconBg: "bg-sky-500/30 text-sky-100",
score: "text-sky-100",
};
}
function initials(name: string | null): string {
if (!name || name.trim() === "") return "?";
const compact = name.trim().slice(0, 2);
return compact.toUpperCase();
}
function playerLabel(side: PlayerSide): string {
if (side.id == null) return "Open slot";
if (side.username && side.username.trim() !== "") return side.username;
return `Player ${side.id}`;
}
function winnerLabel(
winnerId: number | string | null,
left: PlayerSide,
right: PlayerSide,
): string {
if (winnerId == null || winnerId === "") return "No winner yet";
if (idsMatch(winnerId, left.id)) return `${playerLabel(left)} won`;
if (idsMatch(winnerId, right.id)) return `${playerLabel(right)} won`;
return `Winner #${winnerId}`;
}
function PlayerPanel({
side,
isWinner,
isDimmed,
}: {
side: PlayerSide;
isWinner: boolean;
isDimmed: boolean;
}) {
const s = sideStyles(side.color);
return (
<div
className={`relative flex min-h-24 flex-col justify-between rounded-xl border px-2.5 py-2 shadow-lg shadow-black/20 transition ${s.panel} ${
isWinner ? "ring-2 ring-amber-300 shadow-amber-200/30" : ""
} ${isDimmed ? "opacity-50" : "opacity-100"}`}
>
{isWinner ? (
<span className="absolute -top-2 right-2 rounded bg-amber-300 px-1.5 py-0.5 text-[10px] font-black uppercase tracking-wider text-amber-950">
Winner
</span>
) : null}
<span
className={`inline-flex w-fit rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${s.tag}`}
>
{side.color === "red" ? "Home" : "Away"}
</span>
<div className="mt-1.5 flex items-center gap-2">
<div
className={`grid h-8 w-8 place-items-center rounded-full text-xs font-bold ${s.iconBg}`}
>
{initials(side.username)}
</div>
<div className="min-w-0">
<p className="truncate text-xs font-extrabold uppercase tracking-wide text-white">
{playerLabel(side)}
</p>
<p className={`text-[11px] font-semibold tabular-nums ${s.score}`}>
ID: {side.id ?? "—"}
</p>
</div>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] font-semibold text-white/85">
<span className="rounded bg-black/25 px-1.5 py-0.5">
CC {side.cc ?? "—"}
</span>
<span className="rounded bg-black/25 px-1.5 py-0.5">
RC {side.rc ?? "—"}
</span>
</div>
</div>
);
}
export function MatchHistoryBattleCard({
matchId,
statusLabel,
createdAtLabel,
entryFee,
prizeCc,
left,
right,
winnerId,
}: Props) {
const hasWinner =
winnerId != null &&
winnerId !== "" &&
(idsMatch(winnerId, left.id) || idsMatch(winnerId, right.id));
const winnerIsRight = hasWinner && idsMatch(winnerId, right.id);
const displayLeft = winnerIsRight ? right : left;
const displayRight = winnerIsRight ? left : right;
const leftIsWinner = hasWinner && idsMatch(displayLeft.id, winnerId);
const rightIsWinner = hasWinner && idsMatch(displayRight.id, winnerId);
const noWinner = !hasWinner;
return (
<article className="rounded-2xl border border-zinc-200 bg-gradient-to-b from-zinc-100 to-zinc-200 p-2.5 shadow-sm dark:border-zinc-700 dark:from-zinc-900 dark:to-zinc-950">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-white dark:bg-zinc-50 dark:text-zinc-900">
Match #{matchId}
</span>
<span className="rounded-md bg-amber-400/90 px-2 py-1 text-[11px] font-extrabold uppercase tracking-wider text-amber-950">
{statusLabel}
</span>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<PlayerPanel
side={displayLeft}
isWinner={leftIsWinner}
isDimmed={noWinner ? true : !leftIsWinner}
/>
<div className="flex flex-col items-center gap-2">
<div className="rounded-lg border-2 border-amber-300 bg-amber-200/95 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-amber-950">
Final
</div>
<div className="rounded-lg border border-zinc-400 bg-zinc-100 px-2 py-1 text-[11px] font-semibold text-zinc-700 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200">
{winnerLabel(winnerId, displayLeft, displayRight)}
</div>
</div>
<PlayerPanel
side={displayRight}
isWinner={rightIsWinner}
isDimmed={noWinner ? true : !rightIsWinner}
/>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-300/80 pt-1.5 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:text-zinc-300">
<div className="flex flex-wrap gap-2">
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Entry {entryFee ?? "—"}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Prize {prizeCc ?? "—"} CC
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
{createdAtLabel}
</span>
</div>
<Link
href={`/match-logs/${matchId}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-md border border-zinc-400 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-800 shadow-sm transition hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Show logs
</Link>
</div>
</article>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import type { ReactNode } from "react";
/** Bracketed timestamp only `[…]`; space after `]` is not part of the gray stamp. */
const TIMESTAMP = /^(\[[^\]]+\])(\s*)([\s\S]*)$/;
const NORMAL = "text-zinc-50";
const SPAN_RULES: Array<{ re: RegExp; className: string }> = [
{ re: /Red goal scored/, className: "font-semibold !text-rose-400" },
{ re: /Blue goal scored/, className: "font-semibold !text-sky-400" },
{
re: /Dedicated match PATCH success: \d+/,
className: "!text-emerald-400",
},
{ re: /Game started On Server/, className: "!text-amber-400" },
{ re: /Starting server at port \d+/, className: "!text-amber-400" },
{ re: /launching puck at/, className: "!text-cyan-400" },
];
const CLIENT_TEAM = /(Client \d+ set team to )(Red|Blue)/;
type MatchInfo =
| { index: number; len: number; kind: "span"; className: string }
| {
index: number;
len: number;
kind: "clientTeam";
prefix: string;
team: "Red" | "Blue";
};
function pickEarlier(a: MatchInfo, b: MatchInfo): MatchInfo {
if (a.index < b.index) return a;
if (b.index < a.index) return b;
return a.len >= b.len ? a : b;
}
function findNextHighlight(sub: string): MatchInfo | null {
let best: MatchInfo | null = null;
const cm = CLIENT_TEAM.exec(sub);
if (cm) {
best = {
index: cm.index,
len: cm[0].length,
kind: "clientTeam",
prefix: cm[1] ?? "",
team: cm[2] === "Blue" ? "Blue" : "Red",
};
}
for (const { re, className } of SPAN_RULES) {
const m = re.exec(sub);
if (!m || m.index === undefined) continue;
const cand: MatchInfo = {
index: m.index,
len: m[0].length,
kind: "span",
className,
};
best = best ? pickEarlier(best, cand) : cand;
}
return best;
}
function highlightRest(rest: string, lineKey: number): ReactNode[] {
const out: ReactNode[] = [];
let i = 0;
let seg = 0;
while (i < rest.length) {
const sub = rest.slice(i);
const hit = findNextHighlight(sub);
if (!hit) {
out.push(
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
{rest.slice(i)}
</span>,
);
break;
}
const abs = i + hit.index;
if (abs > i) {
out.push(
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
{rest.slice(i, abs)}
</span>,
);
}
if (hit.kind === "span") {
out.push(
<span key={`l${lineKey}-s${seg++}`} className={hit.className}>
{rest.slice(abs, abs + hit.len)}
</span>,
);
} else {
const teamCls =
hit.team === "Red"
? "font-semibold !text-rose-400"
: "font-semibold !text-sky-400";
out.push(
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
{hit.prefix}
</span>,
);
out.push(
<span key={`l${lineKey}-s${seg++}`} className={teamCls}>
{hit.team}
</span>,
);
}
i = abs + hit.len;
}
return out;
}
function formatLogLine(line: string, lineKey: number): ReactNode {
const m = line.match(TIMESTAMP);
if (!m) {
return <span className={NORMAL}>{line}</span>;
}
const bracketTs = m[1] ?? "";
const afterBracket = (m[2] ?? "") + (m[3] ?? "");
return (
<span>
<span className="!text-zinc-500">{bracketTs}</span>
{highlightRest(afterBracket, lineKey)}
</span>
);
}
export function MatchLogColoredBody({ content }: { content: string }) {
const lines = content.split("\n");
return (
<div
className={`${NORMAL} font-mono text-[13px] leading-relaxed break-words selection:bg-emerald-900/50 selection:text-emerald-100`}
>
{lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap">
{formatLogLine(line, i)}
</div>
))}
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
type Props = {
layout: "embedded" | "fullscreen";
source: MatchmakerLogSource;
processedHref: string;
rawHref: string;
content: string;
errorMessage: string | null;
};
/** If the user is within this many px of the bottom, treat as "following" the tail. */
const TAIL_FOLLOW_PX = 80;
const POLL_INTERVAL_MS = 5000;
const REFRESH_BTN =
"shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50";
function segClass(active: boolean) {
return [
"rounded border px-2.5 py-1 font-mono text-xs transition",
active
? "border-emerald-500/60 bg-emerald-950/40 text-emerald-200"
: "border-zinc-600 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-white",
].join(" ");
}
async function fetchLogApi(source: MatchmakerLogSource): Promise<
| { ok: true; content: string }
| { ok: false; message: string }
> {
try {
const params = new URLSearchParams();
if (source === "raw") params.set("mklog", "raw");
params.set("_t", String(Date.now()));
const res = await fetch(`/api/matchmaker-logs?${params.toString()}`, {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const rawText = await res.text();
let data: { content?: string; error?: string };
try {
data = rawText ? (JSON.parse(rawText) as typeof data) : {};
} catch {
return {
ok: false,
message:
"Server returned non-JSON (lost session?). Refresh and sign in again.",
};
}
if (!res.ok) {
return {
ok: false,
message: data.error ?? `Request failed (${res.status})`,
};
}
return { ok: true, content: data.content ?? "" };
} catch (e) {
return {
ok: false,
message: e instanceof Error ? e.message : "Network error",
};
}
}
export function MatchmakerLogTerminal({
layout,
source,
processedHref,
rawHref,
content,
errorMessage,
}: Props) {
const filename =
source === "processed" ? "history.log" : "matchmaker.log";
const scrollRef = useRef<HTMLDivElement>(null);
const tailFollowRef = useRef(true);
/** Avoid overlapping auto + manual fetches (helps dev Strict Mode / double clicks). */
const fetchLockRef = useRef(false);
const [displayContent, setDisplayContent] = useState(content);
const [displayError, setDisplayError] = useState(errorMessage);
const [refreshing, setRefreshing] = useState(false);
/** Bumped on every successful reload so identical file text still updates the UI (React skips re-render when setState equals previous string). */
const [reloadKey, setReloadKey] = useState(0);
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(
null,
);
const runLoad = useCallback(async (manual: boolean) => {
if (
typeof document !== "undefined" &&
document.hidden &&
!manual
) {
return;
}
if (fetchLockRef.current) return;
fetchLockRef.current = true;
if (manual) {
tailFollowRef.current = true;
setRefreshing(true);
}
try {
const result = await fetchLogApi(source);
if (!result.ok) {
setDisplayError(result.message);
return;
}
setDisplayError(null);
setDisplayContent(result.content);
if (manual) {
setReloadKey((k) => k + 1);
setLastFetchedAtMs(Date.now());
}
} finally {
fetchLockRef.current = false;
if (manual) setRefreshing(false);
}
}, [source]);
const refresh = useCallback(async () => {
await runLoad(true);
}, [runLoad]);
const updateTailFollowFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
tailFollowRef.current = gap <= TAIL_FOLLOW_PX;
}, []);
useEffect(() => {
setDisplayContent(content);
setDisplayError(errorMessage);
setReloadKey((k) => k + 1);
}, [content, errorMessage]);
useEffect(() => {
tailFollowRef.current = true;
}, [source]);
useEffect(() => {
let cancelled = false;
async function tick() {
if (cancelled) return;
await runLoad(false);
}
queueMicrotask(tick);
const intervalId = window.setInterval(tick, POLL_INTERVAL_MS);
function onVisibilityChange() {
if (!document.hidden && !cancelled) void tick();
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [source, runLoad]);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !tailFollowRef.current) return;
el.scrollTop = el.scrollHeight;
}, [displayContent, displayError, reloadKey]);
const frameClass =
layout === "fullscreen"
? "flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg"
: "flex h-[calc(100dvh-12rem)] max-h-[calc(100dvh-12rem)] min-h-[200px] shrink-0 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg";
return (
<div className={frameClass}>
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
<div className="flex gap-1.5 pr-2" aria-hidden="true">
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
</div>
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
<span className="text-emerald-400/90">matchmaker</span>
<span className="text-zinc-500"> </span>
<span className="text-zinc-100">{filename}</span>
</p>
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500">
Log
</span>
<Link
href={processedHref}
className={segClass(source === "processed")}
scroll={false}
>
Processed
</Link>
<Link
href={rawHref}
className={segClass(source === "raw")}
scroll={false}
>
Raw
</Link>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className={REFRESH_BTN}
disabled={refreshing}
aria-busy={refreshing}
aria-label="Refresh log from disk"
onClick={() => void refresh()}
>
{refreshing ? "…" : "Refresh"}
</button>
{lastFetchedAtMs != null ? (
<span className="font-mono text-[10px] text-zinc-500">
· manual{" "}
{new Date(lastFetchedAtMs).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
) : null}
<span className="font-mono text-[10px] text-zinc-500">
· auto {POLL_INTERVAL_MS / 1000}s
</span>
</div>
</header>
<div
ref={scrollRef}
onScroll={updateTailFollowFromScroll}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4"
>
{displayError ? (
<p className="font-mono text-sm text-red-400">
<span className="text-red-500/80">error:</span> {displayError}
</p>
) : displayContent === "" ? (
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
) : (
<MatchLogColoredBody
key={reloadKey}
content={displayContent}
/>
)}
</div>
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
<p className="font-mono text-[11px] text-zinc-500">
<span className="text-emerald-600/80"></span> UTF-8
</p>
</footer>
</div>
);
}
+44
View File
@@ -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),
);
}
+5
View File
@@ -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;
+34
View File
@@ -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 (03)
*/
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 03. */
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`;
}
+36
View File
@@ -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}` : "/";
}
+206
View File
@@ -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 };
}
+75
View File
@@ -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." };
}
}
+8
View 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";
}
+82
View File
@@ -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." };
}
}
+104
View 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);
}
+19
View File
@@ -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,
},
});
}
+42
View File
@@ -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)$).*)",
],
};
+32
View File
@@ -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;
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}