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
+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>
);
}