init
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseStoredCoins } from "@/lib/coins-rc";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
|
||||
function normalizeValue(raw: FormDataEntryValue | null): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
function normalizeSettingValue(
|
||||
key: string,
|
||||
raw: FormDataEntryValue | null,
|
||||
): string | null {
|
||||
const s = raw == null ? "" : String(raw).trim();
|
||||
|
||||
if (key === "entry_fee") {
|
||||
if (s === "") return null;
|
||||
const coins = parseStoredCoins(s);
|
||||
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
||||
return String(coins);
|
||||
}
|
||||
|
||||
if (key === "bet_fee") {
|
||||
if (s === "") return null;
|
||||
const n = Math.round(Number(s));
|
||||
if (!Number.isFinite(n)) return "__invalid__";
|
||||
return String(Math.min(100, Math.max(0, n)));
|
||||
}
|
||||
|
||||
return normalizeValue(raw);
|
||||
}
|
||||
|
||||
export async function updateSetting(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const key = String(formData.get("key") ?? "").trim();
|
||||
if (!key) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const value = normalizeSettingValue(key, formData.get("value"));
|
||||
if (value === "__invalid__") {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("settings")
|
||||
.update({ value })
|
||||
.eq("key", key);
|
||||
|
||||
if (error) {
|
||||
redirect("/settings?saveError=1");
|
||||
}
|
||||
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
export async function insertSetting(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const key = String(formData.get("newKey") ?? "").trim();
|
||||
const value = normalizeValue(formData.get("newValue"));
|
||||
|
||||
if (!key) {
|
||||
redirect("/settings?addError=missing");
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect("/settings?addError=config");
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("settings").insert({ key, value });
|
||||
|
||||
if (error) {
|
||||
const code = error.code === "23505" ? "duplicate" : "other";
|
||||
redirect(`/settings?addError=${code}`);
|
||||
}
|
||||
|
||||
redirect("/settings");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
buildDashboardHref,
|
||||
type AdminDashboardTab,
|
||||
} from "@/lib/dashboard-search-url";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
|
||||
function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (s === "") return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function updateUserCcRc(formData: FormData) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const tabRaw = String(formData.get("tab") ?? "");
|
||||
const tab: AdminDashboardTab =
|
||||
tabRaw === "matches"
|
||||
? "matches"
|
||||
: tabRaw === "players"
|
||||
? "players"
|
||||
: tabRaw === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||
const participant =
|
||||
participantRaw === "" ? null : participantRaw;
|
||||
|
||||
const base = { tab, highlightId, participantRaw: participant };
|
||||
|
||||
if (!Number.isInteger(userId) || userId < 1) {
|
||||
redirect(buildDashboardHref(base));
|
||||
}
|
||||
|
||||
const ccRaw = formData.get("cc");
|
||||
const rcRaw = formData.get("rc");
|
||||
const cc = parseScoreField(ccRaw);
|
||||
const rc = parseScoreField(rcRaw);
|
||||
const ccStr = ccRaw == null ? "" : String(ccRaw).trim();
|
||||
const rcStr = rcRaw == null ? "" : String(rcRaw).trim();
|
||||
if ((ccStr !== "" && cc === null) || (rcStr !== "" && rc === null)) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
if (!supabase) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("users")
|
||||
.update({ cc, rc })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
redirect(
|
||||
buildDashboardHref({
|
||||
...base,
|
||||
editId: userId,
|
||||
saveError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
redirect(buildDashboardHref(base));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
applySessionCookie,
|
||||
getSessionCookieSetOptions,
|
||||
} from "@/lib/auth/session-cookie";
|
||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||
|
||||
function invalidRedirect(request: Request) {
|
||||
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
let body: { username?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
username = String(body.username ?? "");
|
||||
password = String(body.password ?? "");
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
username = String(formData.get("username") ?? "");
|
||||
password = String(formData.get("password") ?? "");
|
||||
}
|
||||
|
||||
if (username !== "admin" || password !== "admin") {
|
||||
if (contentType.includes("application/json")) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
return invalidRedirect(request);
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(
|
||||
ADMIN_SESSION_COOKIE,
|
||||
"1",
|
||||
getSessionCookieSetOptions(request),
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||
applySessionCookie(res, request);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(
|
||||
ADMIN_SESSION_COOKIE,
|
||||
"",
|
||||
getSessionCookieClearOptions(request),
|
||||
);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import {
|
||||
parseMatchIdParam,
|
||||
readMatchLogFile,
|
||||
} from "@/lib/match-logs-server";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ matchId: string }> },
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { matchId: raw } = await context.params;
|
||||
const matchId = parseMatchIdParam(raw);
|
||||
if (matchId == null) {
|
||||
return Response.json({ error: "Invalid match id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await readMatchLogFile(matchId);
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.message }, { status: result.status });
|
||||
}
|
||||
|
||||
return Response.json({ matchId, content: result.content });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
return Response.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = parseMatchmakerLogParam(searchParams.get("mklog"));
|
||||
|
||||
const result = await readMatchmakerLogFile(source);
|
||||
if (!result.ok) {
|
||||
return Response.json(
|
||||
{ error: result.message },
|
||||
{ status: result.status, headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ content: result.content },
|
||||
{ headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kick Kings Admin",
|
||||
description: "Admin panel for players and matches",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
type Props = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: Props) {
|
||||
const { error } = await searchParams;
|
||||
const invalid = error === "1";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Admin sign in
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Default:{" "}
|
||||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
admin
|
||||
</code>{" "}
|
||||
/{" "}
|
||||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
admin
|
||||
</code>
|
||||
</p>
|
||||
<form
|
||||
action="/api/auth/login"
|
||||
method="post"
|
||||
className="mt-8 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{invalid ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
Invalid username or password.
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-zinc-900 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||
import {
|
||||
parseMatchIdParam,
|
||||
readMatchLogFile,
|
||||
} from "@/lib/match-logs-server";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ matchId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { matchId } = await params;
|
||||
return {
|
||||
title: `Match ${matchId} log · Kick Kings Admin`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MatchLogPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ matchId: string }>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { matchId: raw } = await params;
|
||||
const matchId = parseMatchIdParam(raw);
|
||||
if (matchId == null) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const result = await readMatchLogFile(matchId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||
<span className="text-emerald-400/90">soccar</span>
|
||||
<span className="text-zinc-500"> — </span>
|
||||
<span className="text-zinc-100">match_{matchId}.txt</span>
|
||||
</p>
|
||||
<Link
|
||||
href="/?tab=matches"
|
||||
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
|
||||
>
|
||||
Back to matches
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{!result.ok ? (
|
||||
<p className="font-mono text-sm text-red-400">
|
||||
<span className="text-red-500/80">error:</span> {result.message}
|
||||
</p>
|
||||
) : result.content === "" ? (
|
||||
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
|
||||
) : (
|
||||
<MatchLogColoredBody content={result.content} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
|
||||
<p className="font-mono text-[11px] text-zinc-500">
|
||||
<span className="text-emerald-600/80">⏎</span> UTF-8
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Matchmaker log · Kick Kings Admin",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MatchmakerLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const sp = await searchParams;
|
||||
const raw =
|
||||
typeof sp.mklog === "string"
|
||||
? sp.mklog
|
||||
: Array.isArray(sp.mklog)
|
||||
? sp.mklog[0]
|
||||
: undefined;
|
||||
const source = parseMatchmakerLogParam(raw ?? null);
|
||||
const result = await readMatchmakerLogFile(source);
|
||||
|
||||
const qp = (s: typeof source) =>
|
||||
s === "raw" ? "?mklog=raw" : "";
|
||||
|
||||
const content = result.ok ? result.content : "";
|
||||
const errorMessage = result.ok ? null : result.message;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-[#0d1117] text-zinc-100">
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<MatchmakerLogTerminal
|
||||
layout="fullscreen"
|
||||
source={source}
|
||||
processedHref={`/matchmaker-logs${qp("processed")}`}
|
||||
rawHref={`/matchmaker-logs${qp("raw")}`}
|
||||
content={content}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
||||
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import type { AdminDashboardTab } from "@/lib/dashboard-search-url";
|
||||
import {
|
||||
parseMatchmakerLogParam,
|
||||
type MatchmakerLogSource,
|
||||
} from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
import type { DbMatch, DbUser } from "@/types/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function firstSearchParam(
|
||||
v: string | string[] | undefined,
|
||||
): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === "string") return v;
|
||||
return v[0] ?? null;
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
tab?: string | string[];
|
||||
highlight?: string | string[];
|
||||
participant?: string | string[];
|
||||
edit?: string | string[];
|
||||
saveError?: string | string[];
|
||||
mklog?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const tabParam = firstSearchParam(sp.tab);
|
||||
const tab: AdminDashboardTab =
|
||||
tabParam === "matches"
|
||||
? "matches"
|
||||
: tabParam === "players"
|
||||
? "players"
|
||||
: tabParam === "matchmaker"
|
||||
? "matchmaker"
|
||||
: "dashboard";
|
||||
const highlightId = firstSearchParam(sp.highlight);
|
||||
const participantRaw = firstSearchParam(sp.participant);
|
||||
const editRaw = firstSearchParam(sp.edit);
|
||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||
const editIdNum =
|
||||
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
let users: DbUser[] = [];
|
||||
let matches: DbMatch[] = [];
|
||||
let configError: string | null = null;
|
||||
let usersError: string | null = null;
|
||||
let matchesError: string | null = null;
|
||||
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||
null;
|
||||
|
||||
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||
let matchmakerContent = "";
|
||||
let matchmakerError: string | null = null;
|
||||
if (tab === "matchmaker") {
|
||||
const mkRaw = firstSearchParam(sp.mklog);
|
||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
||||
const mmRes = await readMatchmakerLogFile(matchmakerSource);
|
||||
if (mmRes.ok) {
|
||||
matchmakerContent = mmRes.content;
|
||||
} else {
|
||||
matchmakerError = mmRes.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
configError =
|
||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||
} else {
|
||||
const usersRes = await supabase
|
||||
.from("users")
|
||||
.select("id, created_at, username, cc, rc, last_logged_at")
|
||||
.order("id", { ascending: false })
|
||||
.limit(500);
|
||||
|
||||
if (usersRes.error) {
|
||||
usersError = usersRes.error.message;
|
||||
} else {
|
||||
users = (usersRes.data ?? []) as DbUser[];
|
||||
}
|
||||
|
||||
const matchesRes = await supabase
|
||||
.from("matches")
|
||||
.select("*")
|
||||
.order("id", { ascending: false })
|
||||
.limit(500);
|
||||
|
||||
if (matchesRes.error) {
|
||||
matchesError = matchesRes.error.message;
|
||||
} else {
|
||||
matches = (matchesRes.data ?? []) as DbMatch[];
|
||||
}
|
||||
|
||||
statsBundle = await loadDashboardStatsBundle(supabase);
|
||||
}
|
||||
|
||||
let editUser: DbUser | null = null;
|
||||
if (Number.isInteger(editIdNum) && editIdNum >= 1) {
|
||||
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
<AdminHeader />
|
||||
{configError ? (
|
||||
<div className="px-6 pt-8">
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{configError}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminDashboard
|
||||
users={users}
|
||||
matches={matches}
|
||||
usersError={usersError}
|
||||
matchesError={matchesError}
|
||||
statsBundle={statsBundle}
|
||||
tab={tab}
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
matchmakerSource={matchmakerSource}
|
||||
matchmakerContent={matchmakerContent}
|
||||
matchmakerError={matchmakerError}
|
||||
/>
|
||||
{editUser ? (
|
||||
<EditUserCcRcOverlay
|
||||
user={editUser}
|
||||
tab={tab}
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
saveError={saveError}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import type { DbSetting } from "@/types/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "Settings · Kick Kings Admin",
|
||||
};
|
||||
}
|
||||
|
||||
function firstSearchParam(
|
||||
v: string | string[] | undefined,
|
||||
): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === "string") return v;
|
||||
return v[0] ?? null;
|
||||
}
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
saveError?: string | string[];
|
||||
addError?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const sp = await searchParams;
|
||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||
const addError = firstSearchParam(sp.addError);
|
||||
|
||||
const supabase = createAdminSupabase();
|
||||
let rows: DbSetting[] = [];
|
||||
let configError: string | null = null;
|
||||
let loadError: string | null = null;
|
||||
|
||||
if (!supabase) {
|
||||
configError =
|
||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||
} else {
|
||||
const res = await supabase
|
||||
.from("settings")
|
||||
.select("key, value")
|
||||
.order("key");
|
||||
|
||||
if (res.error) {
|
||||
loadError = res.error.message;
|
||||
} else {
|
||||
rows = (res.data ?? []) as DbSetting[];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||
<AdminHeader />
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto mb-8 max-w-[900px]">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Global settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Key/value rows in the{" "}
|
||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||
settings
|
||||
</span>{" "}
|
||||
table. Values are stored as text.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{configError ? (
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{configError}
|
||||
</div>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{loadError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AdminSettingsEditor
|
||||
rows={rows}
|
||||
saveError={saveError}
|
||||
addError={addError}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user