init
This commit is contained in:
+41
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6708
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
```sql
|
||||||
|
create table public.settings (
|
||||||
|
key text not null primary key,
|
||||||
|
value text null
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { parseStoredCoins } from "@/lib/coins-rc";
|
||||||
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
function normalizeValue(raw: FormDataEntryValue | null): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
const s = String(raw).trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettingValue(
|
||||||
|
key: string,
|
||||||
|
raw: FormDataEntryValue | null,
|
||||||
|
): string | null {
|
||||||
|
const s = raw == null ? "" : String(raw).trim();
|
||||||
|
|
||||||
|
if (key === "entry_fee") {
|
||||||
|
if (s === "") return null;
|
||||||
|
const coins = parseStoredCoins(s);
|
||||||
|
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
||||||
|
return String(coins);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "bet_fee") {
|
||||||
|
if (s === "") return null;
|
||||||
|
const n = Math.round(Number(s));
|
||||||
|
if (!Number.isFinite(n)) return "__invalid__";
|
||||||
|
return String(Math.min(100, Math.max(0, n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeValue(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(formData: FormData) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
|
if (!key) {
|
||||||
|
redirect("/settings?saveError=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = normalizeSettingValue(key, formData.get("value"));
|
||||||
|
if (value === "__invalid__") {
|
||||||
|
redirect("/settings?saveError=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createAdminSupabase();
|
||||||
|
if (!supabase) {
|
||||||
|
redirect("/settings?saveError=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.update({ value })
|
||||||
|
.eq("key", key);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
redirect("/settings?saveError=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertSetting(formData: FormData) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(formData.get("newKey") ?? "").trim();
|
||||||
|
const value = normalizeValue(formData.get("newValue"));
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
redirect("/settings?addError=missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createAdminSupabase();
|
||||||
|
if (!supabase) {
|
||||||
|
redirect("/settings?addError=config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from("settings").insert({ key, value });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const code = error.code === "23505" ? "duplicate" : "other";
|
||||||
|
redirect(`/settings?addError=${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/settings");
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import {
|
||||||
|
buildDashboardHref,
|
||||||
|
type AdminDashboardTab,
|
||||||
|
} from "@/lib/dashboard-search-url";
|
||||||
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
const s = String(raw).trim();
|
||||||
|
if (s === "") return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserCcRc(formData: FormData) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Number(formData.get("userId"));
|
||||||
|
const tabRaw = String(formData.get("tab") ?? "");
|
||||||
|
const tab: AdminDashboardTab =
|
||||||
|
tabRaw === "matches"
|
||||||
|
? "matches"
|
||||||
|
: tabRaw === "players"
|
||||||
|
? "players"
|
||||||
|
: tabRaw === "matchmaker"
|
||||||
|
? "matchmaker"
|
||||||
|
: "dashboard";
|
||||||
|
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||||
|
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||||
|
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||||
|
const participant =
|
||||||
|
participantRaw === "" ? null : participantRaw;
|
||||||
|
|
||||||
|
const base = { tab, highlightId, participantRaw: participant };
|
||||||
|
|
||||||
|
if (!Number.isInteger(userId) || userId < 1) {
|
||||||
|
redirect(buildDashboardHref(base));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccRaw = formData.get("cc");
|
||||||
|
const rcRaw = formData.get("rc");
|
||||||
|
const cc = parseScoreField(ccRaw);
|
||||||
|
const rc = parseScoreField(rcRaw);
|
||||||
|
const ccStr = ccRaw == null ? "" : String(ccRaw).trim();
|
||||||
|
const rcStr = rcRaw == null ? "" : String(rcRaw).trim();
|
||||||
|
if ((ccStr !== "" && cc === null) || (rcStr !== "" && rc === null)) {
|
||||||
|
redirect(
|
||||||
|
buildDashboardHref({
|
||||||
|
...base,
|
||||||
|
editId: userId,
|
||||||
|
saveError: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createAdminSupabase();
|
||||||
|
if (!supabase) {
|
||||||
|
redirect(
|
||||||
|
buildDashboardHref({
|
||||||
|
...base,
|
||||||
|
editId: userId,
|
||||||
|
saveError: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.update({ cc, rc })
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
redirect(
|
||||||
|
buildDashboardHref({
|
||||||
|
...base,
|
||||||
|
editId: userId,
|
||||||
|
saveError: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(buildDashboardHref(base));
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import {
|
||||||
|
applySessionCookie,
|
||||||
|
getSessionCookieSetOptions,
|
||||||
|
} from "@/lib/auth/session-cookie";
|
||||||
|
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||||
|
|
||||||
|
function invalidRedirect(request: Request) {
|
||||||
|
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
let username: string;
|
||||||
|
let password: string;
|
||||||
|
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
let body: { username?: string; password?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
username = String(body.username ?? "");
|
||||||
|
password = String(body.password ?? "");
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
username = String(formData.get("username") ?? "");
|
||||||
|
password = String(formData.get("password") ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== "admin" || password !== "admin") {
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return invalidRedirect(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
"1",
|
||||||
|
getSessionCookieSetOptions(request),
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||||
|
applySessionCookie(res, request);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
"",
|
||||||
|
getSessionCookieClearOptions(request),
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import {
|
||||||
|
parseMatchIdParam,
|
||||||
|
readMatchLogFile,
|
||||||
|
} from "@/lib/match-logs-server";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ matchId: string }> },
|
||||||
|
) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId: raw } = await context.params;
|
||||||
|
const matchId = parseMatchIdParam(raw);
|
||||||
|
if (matchId == null) {
|
||||||
|
return Response.json({ error: "Invalid match id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await readMatchLogFile(matchId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json({ error: result.message }, { status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ matchId, content: result.content });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
|
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401, headers: NO_STORE },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const source = parseMatchmakerLogParam(searchParams.get("mklog"));
|
||||||
|
|
||||||
|
const result = await readMatchmakerLogFile(source);
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: result.message },
|
||||||
|
{ status: result.status, headers: NO_STORE },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ content: result.content },
|
||||||
|
{ headers: NO_STORE },
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Kick Kings Admin",
|
||||||
|
description: "Admin panel for players and matches",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
type Props = {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: Props) {
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const invalid = error === "1";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Admin sign in
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Default:{" "}
|
||||||
|
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||||
|
admin
|
||||||
|
</code>{" "}
|
||||||
|
/{" "}
|
||||||
|
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||||
|
admin
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="/api/auth/login"
|
||||||
|
method="post"
|
||||||
|
className="mt-8 space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-zinc-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{invalid ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
Invalid username or password.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded-lg bg-zinc-900 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||||
|
import {
|
||||||
|
parseMatchIdParam,
|
||||||
|
readMatchLogFile,
|
||||||
|
} from "@/lib/match-logs-server";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ matchId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { matchId } = await params;
|
||||||
|
return {
|
||||||
|
title: `Match ${matchId} log · Kick Kings Admin`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MatchLogPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ matchId: string }>;
|
||||||
|
}) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { matchId: raw } = await params;
|
||||||
|
const matchId = parseMatchIdParam(raw);
|
||||||
|
if (matchId == null) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await readMatchLogFile(matchId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
|
||||||
|
<header className="flex shrink-0 items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||||
|
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||||
|
</div>
|
||||||
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||||
|
<span className="text-emerald-400/90">soccar</span>
|
||||||
|
<span className="text-zinc-500"> — </span>
|
||||||
|
<span className="text-zinc-100">match_{matchId}.txt</span>
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/?tab=matches"
|
||||||
|
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to matches
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
{!result.ok ? (
|
||||||
|
<p className="font-mono text-sm text-red-400">
|
||||||
|
<span className="text-red-500/80">error:</span> {result.message}
|
||||||
|
</p>
|
||||||
|
) : result.content === "" ? (
|
||||||
|
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
|
||||||
|
) : (
|
||||||
|
<MatchLogColoredBody content={result.content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
|
||||||
|
<p className="font-mono text-[11px] text-zinc-500">
|
||||||
|
<span className="text-emerald-600/80">⏎</span> UTF-8
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Matchmaker log · Kick Kings Admin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MatchmakerLogsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||||
|
}) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const raw =
|
||||||
|
typeof sp.mklog === "string"
|
||||||
|
? sp.mklog
|
||||||
|
: Array.isArray(sp.mklog)
|
||||||
|
? sp.mklog[0]
|
||||||
|
: undefined;
|
||||||
|
const source = parseMatchmakerLogParam(raw ?? null);
|
||||||
|
const result = await readMatchmakerLogFile(source);
|
||||||
|
|
||||||
|
const qp = (s: typeof source) =>
|
||||||
|
s === "raw" ? "?mklog=raw" : "";
|
||||||
|
|
||||||
|
const content = result.ok ? result.content : "";
|
||||||
|
const errorMessage = result.ok ? null : result.message;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-[#0d1117] text-zinc-100">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||||
|
<MatchmakerLogTerminal
|
||||||
|
layout="fullscreen"
|
||||||
|
source={source}
|
||||||
|
processedHref={`/matchmaker-logs${qp("processed")}`}
|
||||||
|
rawHref={`/matchmaker-logs${qp("raw")}`}
|
||||||
|
content={content}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||||
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
|
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
||||||
|
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
import type { AdminDashboardTab } from "@/lib/dashboard-search-url";
|
||||||
|
import {
|
||||||
|
parseMatchmakerLogParam,
|
||||||
|
type MatchmakerLogSource,
|
||||||
|
} from "@/lib/matchmaker-log-source";
|
||||||
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
import type { DbMatch, DbUser } from "@/types/database";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function firstSearchParam(
|
||||||
|
v: string | string[] | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (v === undefined) return null;
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
return v[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
tab?: string | string[];
|
||||||
|
highlight?: string | string[];
|
||||||
|
participant?: string | string[];
|
||||||
|
edit?: string | string[];
|
||||||
|
saveError?: string | string[];
|
||||||
|
mklog?: string | string[];
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const tabParam = firstSearchParam(sp.tab);
|
||||||
|
const tab: AdminDashboardTab =
|
||||||
|
tabParam === "matches"
|
||||||
|
? "matches"
|
||||||
|
: tabParam === "players"
|
||||||
|
? "players"
|
||||||
|
: tabParam === "matchmaker"
|
||||||
|
? "matchmaker"
|
||||||
|
: "dashboard";
|
||||||
|
const highlightId = firstSearchParam(sp.highlight);
|
||||||
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
|
const editIdNum =
|
||||||
|
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||||
|
|
||||||
|
const supabase = createAdminSupabase();
|
||||||
|
let users: DbUser[] = [];
|
||||||
|
let matches: DbMatch[] = [];
|
||||||
|
let configError: string | null = null;
|
||||||
|
let usersError: string | null = null;
|
||||||
|
let matchesError: string | null = null;
|
||||||
|
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||||
|
let matchmakerContent = "";
|
||||||
|
let matchmakerError: string | null = null;
|
||||||
|
if (tab === "matchmaker") {
|
||||||
|
const mkRaw = firstSearchParam(sp.mklog);
|
||||||
|
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
||||||
|
const mmRes = await readMatchmakerLogFile(matchmakerSource);
|
||||||
|
if (mmRes.ok) {
|
||||||
|
matchmakerContent = mmRes.content;
|
||||||
|
} else {
|
||||||
|
matchmakerError = mmRes.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
configError =
|
||||||
|
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||||
|
} else {
|
||||||
|
const usersRes = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, created_at, username, cc, rc, last_logged_at")
|
||||||
|
.order("id", { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
if (usersRes.error) {
|
||||||
|
usersError = usersRes.error.message;
|
||||||
|
} else {
|
||||||
|
users = (usersRes.data ?? []) as DbUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesRes = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("*")
|
||||||
|
.order("id", { ascending: false })
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
if (matchesRes.error) {
|
||||||
|
matchesError = matchesRes.error.message;
|
||||||
|
} else {
|
||||||
|
matches = (matchesRes.data ?? []) as DbMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
statsBundle = await loadDashboardStatsBundle(supabase);
|
||||||
|
}
|
||||||
|
|
||||||
|
let editUser: DbUser | null = null;
|
||||||
|
if (Number.isInteger(editIdNum) && editIdNum >= 1) {
|
||||||
|
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AdminHeader />
|
||||||
|
{configError ? (
|
||||||
|
<div className="px-6 pt-8">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{configError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AdminDashboard
|
||||||
|
users={users}
|
||||||
|
matches={matches}
|
||||||
|
usersError={usersError}
|
||||||
|
matchesError={matchesError}
|
||||||
|
statsBundle={statsBundle}
|
||||||
|
tab={tab}
|
||||||
|
highlightId={highlightId}
|
||||||
|
participantRaw={participantRaw}
|
||||||
|
matchmakerSource={matchmakerSource}
|
||||||
|
matchmakerContent={matchmakerContent}
|
||||||
|
matchmakerError={matchmakerError}
|
||||||
|
/>
|
||||||
|
{editUser ? (
|
||||||
|
<EditUserCcRcOverlay
|
||||||
|
user={editUser}
|
||||||
|
tab={tab}
|
||||||
|
highlightId={highlightId}
|
||||||
|
participantRaw={participantRaw}
|
||||||
|
saveError={saveError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
|
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
import type { DbSetting } from "@/types/database";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "Settings · Kick Kings Admin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstSearchParam(
|
||||||
|
v: string | string[] | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (v === undefined) return null;
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
return v[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
saveError?: string | string[];
|
||||||
|
addError?: string | string[];
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
|
const addError = firstSearchParam(sp.addError);
|
||||||
|
|
||||||
|
const supabase = createAdminSupabase();
|
||||||
|
let rows: DbSetting[] = [];
|
||||||
|
let configError: string | null = null;
|
||||||
|
let loadError: string | null = null;
|
||||||
|
|
||||||
|
if (!supabase) {
|
||||||
|
configError =
|
||||||
|
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||||
|
} else {
|
||||||
|
const res = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.select("key, value")
|
||||||
|
.order("key");
|
||||||
|
|
||||||
|
if (res.error) {
|
||||||
|
loadError = res.error.message;
|
||||||
|
} else {
|
||||||
|
rows = (res.data ?? []) as DbSetting[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AdminHeader />
|
||||||
|
<main className="flex-1 px-6 py-8">
|
||||||
|
<div className="mx-auto mb-8 max-w-[900px]">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
Global settings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Key/value rows in the{" "}
|
||||||
|
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||||
|
settings
|
||||||
|
</span>{" "}
|
||||||
|
table. Values are stored as text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configError ? (
|
||||||
|
<div className="mx-auto max-w-[900px]">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{configError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div className="mx-auto max-w-[900px]">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AdminSettingsEditor
|
||||||
|
rows={rows}
|
||||||
|
saveError={saveError}
|
||||||
|
addError={addError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||||
|
import type { DashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
|
import {
|
||||||
|
buildDashboardHref,
|
||||||
|
type AdminDashboardTab,
|
||||||
|
} from "@/lib/dashboard-search-url";
|
||||||
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
|
import type { DbMatch, DbUser } from "@/types/database";
|
||||||
|
|
||||||
|
/** Bigint columns often arrive as strings from PostgREST / JSON. */
|
||||||
|
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
|
||||||
|
if (winnerId == null) return false;
|
||||||
|
if (typeof winnerId === "number") {
|
||||||
|
return Number.isInteger(winnerId) && winnerId > 0;
|
||||||
|
}
|
||||||
|
if (typeof winnerId === "string") {
|
||||||
|
const t = winnerId.trim();
|
||||||
|
if (t === "" || !/^-?\d+$/.test(t)) return false;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) && n > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function participantInMatch(m: DbMatch, participantId: number): boolean {
|
||||||
|
const red = m.user_red;
|
||||||
|
const blue = m.user_blue;
|
||||||
|
if (red != null && Number(red) === participantId) return true;
|
||||||
|
if (blue != null && Number(blue) === participantId) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coalesceUserId(v: number | string | null): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */
|
||||||
|
function formatTs(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
|
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
users: DbUser[];
|
||||||
|
matches: DbMatch[];
|
||||||
|
usersError: string | null;
|
||||||
|
matchesError: string | null;
|
||||||
|
statsBundle: DashboardStatsBundle | null;
|
||||||
|
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
|
||||||
|
tab: AdminDashboardTab;
|
||||||
|
highlightId: string | null;
|
||||||
|
participantRaw: string | null;
|
||||||
|
matchmakerSource: MatchmakerLogSource;
|
||||||
|
matchmakerContent: string;
|
||||||
|
matchmakerError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number | null;
|
||||||
|
}) {
|
||||||
|
const display =
|
||||||
|
value == null ? "—" : value.toLocaleString("en-US");
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
{display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDashboard({
|
||||||
|
users,
|
||||||
|
matches,
|
||||||
|
usersError,
|
||||||
|
matchesError,
|
||||||
|
statsBundle,
|
||||||
|
tab,
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
matchmakerSource,
|
||||||
|
matchmakerContent,
|
||||||
|
matchmakerError,
|
||||||
|
}: Props) {
|
||||||
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
|
|
||||||
|
const userById = useMemo(() => {
|
||||||
|
const m = new Map<number, DbUser>();
|
||||||
|
for (const u of users) {
|
||||||
|
m.set(u.id, u);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const usernameById = useMemo(() => {
|
||||||
|
const m = new Map<number, string | null>();
|
||||||
|
for (const u of users) {
|
||||||
|
m.set(u.id, u.username);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const participantId = useMemo(() => {
|
||||||
|
if (!participantRaw) return null;
|
||||||
|
const n = Number(participantRaw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}, [participantRaw]);
|
||||||
|
|
||||||
|
const filteredMatches = useMemo(() => {
|
||||||
|
if (participantId == null) return matches;
|
||||||
|
return matches.filter((m) => participantInMatch(m, participantId));
|
||||||
|
}, [matches, participantId]);
|
||||||
|
const visibleMatches = useMemo(() => {
|
||||||
|
if (!hideNoWinner) return filteredMatches;
|
||||||
|
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
||||||
|
}, [filteredMatches, hideNoWinner]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "players" || !highlightId) return;
|
||||||
|
const el = document.getElementById(`player-row-${highlightId}`);
|
||||||
|
el?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
}, [tab, highlightId]);
|
||||||
|
|
||||||
|
const tabClass = (active: boolean) =>
|
||||||
|
[
|
||||||
|
"inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition",
|
||||||
|
active
|
||||||
|
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
|
||||||
|
: "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
function editHref(u: DbUser): string {
|
||||||
|
return buildDashboardHref({
|
||||||
|
tab,
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
editId: u.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsersLabel =
|
||||||
|
statsBundle?.stats?.totalUsers ?? users.length;
|
||||||
|
const totalMatchesLabel =
|
||||||
|
statsBundle?.stats?.totalMatches ?? matches.length;
|
||||||
|
const statusLabel = (status: number | null) => {
|
||||||
|
if (status === 2) return "Final";
|
||||||
|
if (status === 1) return "Live";
|
||||||
|
if (status === 0) return "Waiting";
|
||||||
|
return status == null ? "Unknown" : `Status ${status}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 space-y-6 px-6 py-8">
|
||||||
|
<div className="flex flex-wrap gap-2 border-b border-zinc-200 pb-4 dark:border-zinc-800">
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "dashboard",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "dashboard")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "players",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "players")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Players (
|
||||||
|
{totalUsersLabel.toLocaleString("en-US")}
|
||||||
|
{!statsBundle?.stats
|
||||||
|
? ` · table ${users.length.toLocaleString("en-US")}`
|
||||||
|
: null}
|
||||||
|
)
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "matches",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "matches")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Matches (
|
||||||
|
{totalMatchesLabel.toLocaleString("en-US")}
|
||||||
|
{!statsBundle?.stats
|
||||||
|
? ` · table ${matches.length.toLocaleString("en-US")}`
|
||||||
|
: null}
|
||||||
|
)
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={buildDashboardHref({
|
||||||
|
tab: "matchmaker",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
className={tabClass(tab === "matchmaker")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Matchmaker
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={tabClass(false)}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-[1400px]">
|
||||||
|
{tab === "dashboard" ? (
|
||||||
|
<section className="space-y-8">
|
||||||
|
{statsBundle?.error ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{statsBundle.error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-8 xl:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Active players
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Active players today"
|
||||||
|
value={statsBundle?.stats?.activePlayersToday ?? null}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active players (last 7 days)"
|
||||||
|
value={statsBundle?.stats?.activePlayersLastWeek ?? null}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active players (last 30 days)"
|
||||||
|
value={statsBundle?.stats?.activePlayersLastMonth ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Matches
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Matches today"
|
||||||
|
value={statsBundle?.stats?.matchesToday ?? null}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Matches (last 7 days)"
|
||||||
|
value={statsBundle?.stats?.matchesLastWeek ?? null}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Matches (last 30 days)"
|
||||||
|
value={statsBundle?.stats?.matchesLastMonth ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<StatCard
|
||||||
|
title="Total users"
|
||||||
|
value={statsBundle?.stats?.totalUsers ?? null}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total matches"
|
||||||
|
value={statsBundle?.stats?.totalMatches ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Top players by match winnings
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">#</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Player</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">
|
||||||
|
Total prize CC
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||||
|
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-4 py-8 text-center text-zinc-500"
|
||||||
|
>
|
||||||
|
No wins recorded yet (no rows with a winner).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
(statsBundle?.leaderboard ?? []).map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={row.userId}
|
||||||
|
className="text-zinc-800 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs text-zinc-500">
|
||||||
|
{i + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/?tab=players&highlight=${row.userId}`}
|
||||||
|
className="font-mono text-xs text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
{row.userId}
|
||||||
|
{row.username != null && row.username !== "" ? (
|
||||||
|
<span className="text-zinc-700 dark:text-zinc-300">
|
||||||
|
{" "}
|
||||||
|
({row.username})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-500"> (—)</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono tabular-nums">
|
||||||
|
{row.totalPrizeCc.toLocaleString("en-US")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : tab === "players" ? (
|
||||||
|
<section>
|
||||||
|
{usersError ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">ID</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Username</th>
|
||||||
|
<th className="px-4 py-3 font-medium">CC</th>
|
||||||
|
<th className="px-4 py-3 font-medium">RC</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Last seen</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-4 py-8 text-center text-zinc-500"
|
||||||
|
>
|
||||||
|
No players yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((u) => {
|
||||||
|
const isHi =
|
||||||
|
highlightId != null &&
|
||||||
|
highlightId === String(u.id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
id={`player-row-${u.id}`}
|
||||||
|
className={
|
||||||
|
isHi
|
||||||
|
? "bg-amber-100/90 ring-2 ring-inset ring-amber-400/80 dark:bg-amber-950/50 dark:ring-amber-500/60"
|
||||||
|
: "text-zinc-800 dark:text-zinc-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{u.id}</td>
|
||||||
|
<td className="px-4 py-2">{u.username ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2">{u.cc ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
{formatTs(u.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
{formatTs(u.last_logged_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={editHref(u)}
|
||||||
|
scroll={false}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/?tab=matches&participant=${u.id}`}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Show matches
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : tab === "matchmaker" ? (
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
<p>
|
||||||
|
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||||||
|
history.log
|
||||||
|
</span>{" "}
|
||||||
|
is the processed summary;{" "}
|
||||||
|
<span className="font-mono text-zinc-700 dark:text-zinc-300">
|
||||||
|
matchmaker.log
|
||||||
|
</span>{" "}
|
||||||
|
is raw output. Toggle below or{" "}
|
||||||
|
<Link
|
||||||
|
href={`/matchmaker-logs${matchmakerSource === "raw" ? "?mklog=raw" : ""}`}
|
||||||
|
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
open fullscreen
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MatchmakerLogTerminal
|
||||||
|
layout="embedded"
|
||||||
|
source={matchmakerSource}
|
||||||
|
processedHref={buildDashboardHref({
|
||||||
|
tab: "matchmaker",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
})}
|
||||||
|
rawHref={buildDashboardHref({
|
||||||
|
tab: "matchmaker",
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
matchmakerSource: "raw",
|
||||||
|
})}
|
||||||
|
content={matchmakerContent}
|
||||||
|
errorMessage={matchmakerError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{participantId != null ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Showing matches where{" "}
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
{participantId}
|
||||||
|
</span>
|
||||||
|
{usernameById.get(participantId) != null &&
|
||||||
|
usernameById.get(participantId) !== "" ? (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
({usernameById.get(participantId)})
|
||||||
|
</span>
|
||||||
|
) : null}{" "}
|
||||||
|
is the Red or Blue user (
|
||||||
|
{filteredMatches.length} of {matches.length})
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href="/?tab=matches"
|
||||||
|
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{matchesError ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{matchesError}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="relative z-10 mb-1 flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<label
|
||||||
|
htmlFor="admin-hide-matches-no-winner"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 text-zinc-700 dark:text-zinc-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="admin-hide-matches-no-winner"
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideNoWinner}
|
||||||
|
onChange={(e) => setHideNoWinner(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
|
||||||
|
/>
|
||||||
|
Hide matches with no winner
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
Showing {visibleMatches.length} of {filteredMatches.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{visibleMatches.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
{filteredMatches.length === 0
|
||||||
|
? "No matches yet."
|
||||||
|
: hideNoWinner
|
||||||
|
? "No matches left after hiding no-winner matches."
|
||||||
|
: "No matches for this filter."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleMatches.map((m) => {
|
||||||
|
const redId = coalesceUserId(m.user_red);
|
||||||
|
const blueId = coalesceUserId(m.user_blue);
|
||||||
|
const leftUser =
|
||||||
|
redId != null ? userById.get(redId) : null;
|
||||||
|
const rightUser =
|
||||||
|
blueId != null ? userById.get(blueId) : null;
|
||||||
|
return (
|
||||||
|
<MatchHistoryBattleCard
|
||||||
|
key={m.id}
|
||||||
|
matchId={m.id}
|
||||||
|
statusLabel={statusLabel(m.status)}
|
||||||
|
createdAtLabel={formatTs(m.created_at)}
|
||||||
|
entryFee={m.entry_fee}
|
||||||
|
prizeCc={m.prize_cc}
|
||||||
|
winnerId={m.winner_id}
|
||||||
|
left={{
|
||||||
|
id: redId,
|
||||||
|
username: leftUser?.username ?? null,
|
||||||
|
cc: leftUser?.cc ?? null,
|
||||||
|
rc: leftUser?.rc ?? null,
|
||||||
|
color: "red",
|
||||||
|
}}
|
||||||
|
right={{
|
||||||
|
id: blueId,
|
||||||
|
username: rightUser?.username ?? null,
|
||||||
|
cc: rightUser?.cc ?? null,
|
||||||
|
rc: rightUser?.rc ?? null,
|
||||||
|
color: "blue",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export function AdminHeader() {
|
||||||
|
async function logout() {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-zinc-200 bg-white px-6 py-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
Kick Kings Admin Dashboard
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
insertSetting,
|
||||||
|
updateSetting,
|
||||||
|
} from "@/app/actions/settings-actions";
|
||||||
|
import {
|
||||||
|
coinsToRc,
|
||||||
|
parseStoredCoins,
|
||||||
|
rcToCoins,
|
||||||
|
} from "@/lib/coins-rc";
|
||||||
|
import type { DbSetting } from "@/types/database";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: DbSetting[];
|
||||||
|
saveError: boolean;
|
||||||
|
addError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updateSetting}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor={`setting-value-${index}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||||
|
{row.key}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`setting-value-${index}`}
|
||||||
|
name="value"
|
||||||
|
type="text"
|
||||||
|
defaultValue={row.value ?? ""}
|
||||||
|
autoComplete="off"
|
||||||
|
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||||
|
const initial = Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, Math.round(Number(row.value ?? 0))),
|
||||||
|
);
|
||||||
|
const [v, setV] = useState(initial);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updateSetting}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<input type="hidden" name="value" value={String(v)} />
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor={`bet-fee-${index}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||||
|
{row.key}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
0–100 (saved as the number shown).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<input
|
||||||
|
id={`bet-fee-${index}`}
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => setV(Number(e.target.value))}
|
||||||
|
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||||
|
/>
|
||||||
|
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||||
|
const initialCoins = parseStoredCoins(row.value);
|
||||||
|
const initialRc = coinsToRc(initialCoins);
|
||||||
|
const [rcText, setRcText] = useState(() => initialRc.toFixed(1));
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const coinsPreview = useMemo(() => {
|
||||||
|
const t = rcText.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
return rcToCoins(Number(t));
|
||||||
|
}, [rcText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={updateSetting}
|
||||||
|
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
setLocalError(null);
|
||||||
|
const trimmed = rcText.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
e.preventDefault();
|
||||||
|
setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rc = Number(trimmed);
|
||||||
|
const coins = rcToCoins(rc);
|
||||||
|
if (coins === null) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLocalError(
|
||||||
|
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="value"
|
||||||
|
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
|
htmlFor={`entry-fee-rc-${index}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||||
|
{row.key}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||||
|
(edit as RC; stored as coins)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`entry-fee-rc-${index}`}
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
autoComplete="off"
|
||||||
|
value={rcText}
|
||||||
|
onChange={(e) => setRcText(e.target.value)}
|
||||||
|
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
{localError ? (
|
||||||
|
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{localError}
|
||||||
|
</p>
|
||||||
|
) : coinsPreview !== null ? (
|
||||||
|
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
|
||||||
|
database.
|
||||||
|
</p>
|
||||||
|
) : rcText.trim() !== "" ? (
|
||||||
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Not a valid RC encoding yet — fix the value to save.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||||
|
if (row.key === "bet_fee") {
|
||||||
|
return <BetFeeRow row={row} index={index} />;
|
||||||
|
}
|
||||||
|
if (row.key === "entry_fee") {
|
||||||
|
return <EntryFeeRow row={row} index={index} />;
|
||||||
|
}
|
||||||
|
return <DefaultSettingRow row={row} index={index} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSettingsEditor({
|
||||||
|
rows,
|
||||||
|
saveError,
|
||||||
|
addError,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||||
|
{saveError ? (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Could not save that row. Try again or check Supabase connectivity.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Existing keys
|
||||||
|
</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
|
||||||
|
No settings rows yet. Add one below.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<SettingRow key={row.key} row={row} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||||
|
Add setting
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{addError === "duplicate" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
That key already exists. Edit it in the list above instead.
|
||||||
|
</p>
|
||||||
|
) : addError === "missing" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
Enter a non-empty key.
|
||||||
|
</p>
|
||||||
|
) : addError === "config" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
Supabase admin client is not configured.
|
||||||
|
</p>
|
||||||
|
) : addError === "other" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
Could not insert. Check constraints and retry.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form action={insertSetting} className="mt-4 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="new-setting-key"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-setting-key"
|
||||||
|
name="newKey"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="e.g. maintenance_message"
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="new-setting-value"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-setting-value"
|
||||||
|
name="newValue"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Add row
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { updateUserCcRc } from "@/app/actions/update-user-cc-rc";
|
||||||
|
import {
|
||||||
|
buildDashboardHref,
|
||||||
|
type AdminDashboardTab,
|
||||||
|
} from "@/lib/dashboard-search-url";
|
||||||
|
import type { DbUser } from "@/types/database";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: DbUser;
|
||||||
|
tab: AdminDashboardTab;
|
||||||
|
highlightId: string | null;
|
||||||
|
participantRaw: string | null;
|
||||||
|
saveError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditUserCcRcOverlay({
|
||||||
|
user,
|
||||||
|
tab,
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
saveError,
|
||||||
|
}: Props) {
|
||||||
|
const cancelHref = buildDashboardHref({
|
||||||
|
tab,
|
||||||
|
highlightId,
|
||||||
|
participantRaw,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="edit-cc-rc-title"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<h2
|
||||||
|
id="edit-cc-rc-title"
|
||||||
|
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Edit CC & RC
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Player{" "}
|
||||||
|
<span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
{user.id}
|
||||||
|
</span>
|
||||||
|
{user.username ? <span> ({user.username})</span> : null}
|
||||||
|
</p>
|
||||||
|
{saveError ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
Could not save. Check that CC and RC are valid numbers or empty.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<form action={updateUserCcRc} className="mt-5 space-y-4">
|
||||||
|
<input type="hidden" name="userId" value={user.id} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="tab"
|
||||||
|
value={
|
||||||
|
tab === "matches"
|
||||||
|
? "matches"
|
||||||
|
: tab === "players"
|
||||||
|
? "players"
|
||||||
|
: "dashboard"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="highlightId"
|
||||||
|
value={highlightId ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="participantRaw"
|
||||||
|
value={participantRaw ?? ""}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-cc-${user.id}`}
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
CC
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-cc-${user.id}`}
|
||||||
|
name="cc"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
defaultValue={user.cc == null ? "" : String(user.cc)}
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`edit-rc-${user.id}`}
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
RC
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-rc-${user.id}`}
|
||||||
|
name="rc"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
defaultValue={user.rc == null ? "" : String(user.rc)}
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={cancelHref}
|
||||||
|
scroll={false}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type PlayerSide = {
|
||||||
|
id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
cc: number | null;
|
||||||
|
rc: number | null;
|
||||||
|
color: "red" | "blue";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
matchId: number;
|
||||||
|
statusLabel: string;
|
||||||
|
createdAtLabel: string;
|
||||||
|
entryFee: number | null;
|
||||||
|
prizeCc: number | null;
|
||||||
|
left: PlayerSide;
|
||||||
|
right: PlayerSide;
|
||||||
|
/** May be string when `bigint` is JSON-serialized. */
|
||||||
|
winnerId: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function idsMatch(
|
||||||
|
a: number | string | null | undefined,
|
||||||
|
b: number | string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (a == null || b == null) return false;
|
||||||
|
const na = typeof a === "number" ? a : Number(a);
|
||||||
|
const nb = typeof b === "number" ? b : Number(b);
|
||||||
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideStyles(color: PlayerSide["color"]) {
|
||||||
|
if (color === "red") {
|
||||||
|
return {
|
||||||
|
panel:
|
||||||
|
"border-rose-500/60 bg-gradient-to-br from-rose-700/80 via-fuchsia-700/70 to-violet-700/80",
|
||||||
|
tag: "bg-rose-500/90 text-rose-50",
|
||||||
|
iconBg: "bg-rose-500/30 text-rose-100",
|
||||||
|
score: "text-rose-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
panel:
|
||||||
|
"border-sky-500/60 bg-gradient-to-br from-sky-700/80 via-indigo-700/70 to-violet-700/80",
|
||||||
|
tag: "bg-sky-500/90 text-sky-50",
|
||||||
|
iconBg: "bg-sky-500/30 text-sky-100",
|
||||||
|
score: "text-sky-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string | null): string {
|
||||||
|
if (!name || name.trim() === "") return "?";
|
||||||
|
const compact = name.trim().slice(0, 2);
|
||||||
|
return compact.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerLabel(side: PlayerSide): string {
|
||||||
|
if (side.id == null) return "Open slot";
|
||||||
|
if (side.username && side.username.trim() !== "") return side.username;
|
||||||
|
return `Player ${side.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function winnerLabel(
|
||||||
|
winnerId: number | string | null,
|
||||||
|
left: PlayerSide,
|
||||||
|
right: PlayerSide,
|
||||||
|
): string {
|
||||||
|
if (winnerId == null || winnerId === "") return "No winner yet";
|
||||||
|
if (idsMatch(winnerId, left.id)) return `${playerLabel(left)} won`;
|
||||||
|
if (idsMatch(winnerId, right.id)) return `${playerLabel(right)} won`;
|
||||||
|
return `Winner #${winnerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerPanel({
|
||||||
|
side,
|
||||||
|
isWinner,
|
||||||
|
isDimmed,
|
||||||
|
}: {
|
||||||
|
side: PlayerSide;
|
||||||
|
isWinner: boolean;
|
||||||
|
isDimmed: boolean;
|
||||||
|
}) {
|
||||||
|
const s = sideStyles(side.color);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex min-h-24 flex-col justify-between rounded-xl border px-2.5 py-2 shadow-lg shadow-black/20 transition ${s.panel} ${
|
||||||
|
isWinner ? "ring-2 ring-amber-300 shadow-amber-200/30" : ""
|
||||||
|
} ${isDimmed ? "opacity-50" : "opacity-100"}`}
|
||||||
|
>
|
||||||
|
{isWinner ? (
|
||||||
|
<span className="absolute -top-2 right-2 rounded bg-amber-300 px-1.5 py-0.5 text-[10px] font-black uppercase tracking-wider text-amber-950">
|
||||||
|
Winner
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={`inline-flex w-fit rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${s.tag}`}
|
||||||
|
>
|
||||||
|
{side.color === "red" ? "Home" : "Away"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`grid h-8 w-8 place-items-center rounded-full text-xs font-bold ${s.iconBg}`}
|
||||||
|
>
|
||||||
|
{initials(side.username)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-extrabold uppercase tracking-wide text-white">
|
||||||
|
{playerLabel(side)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-[11px] font-semibold tabular-nums ${s.score}`}>
|
||||||
|
ID: {side.id ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-[10px] font-semibold text-white/85">
|
||||||
|
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
||||||
|
CC {side.cc ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-black/25 px-1.5 py-0.5">
|
||||||
|
RC {side.rc ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchHistoryBattleCard({
|
||||||
|
matchId,
|
||||||
|
statusLabel,
|
||||||
|
createdAtLabel,
|
||||||
|
entryFee,
|
||||||
|
prizeCc,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
winnerId,
|
||||||
|
}: Props) {
|
||||||
|
const hasWinner =
|
||||||
|
winnerId != null &&
|
||||||
|
winnerId !== "" &&
|
||||||
|
(idsMatch(winnerId, left.id) || idsMatch(winnerId, right.id));
|
||||||
|
const winnerIsRight = hasWinner && idsMatch(winnerId, right.id);
|
||||||
|
const displayLeft = winnerIsRight ? right : left;
|
||||||
|
const displayRight = winnerIsRight ? left : right;
|
||||||
|
const leftIsWinner = hasWinner && idsMatch(displayLeft.id, winnerId);
|
||||||
|
const rightIsWinner = hasWinner && idsMatch(displayRight.id, winnerId);
|
||||||
|
const noWinner = !hasWinner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-zinc-200 bg-gradient-to-b from-zinc-100 to-zinc-200 p-2.5 shadow-sm dark:border-zinc-700 dark:from-zinc-900 dark:to-zinc-950">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-bold uppercase tracking-wider text-white dark:bg-zinc-50 dark:text-zinc-900">
|
||||||
|
Match #{matchId}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-amber-400/90 px-2 py-1 text-[11px] font-extrabold uppercase tracking-wider text-amber-950">
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
|
<PlayerPanel
|
||||||
|
side={displayLeft}
|
||||||
|
isWinner={leftIsWinner}
|
||||||
|
isDimmed={noWinner ? true : !leftIsWinner}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="rounded-lg border-2 border-amber-300 bg-amber-200/95 px-2 py-0.5 text-[11px] font-black uppercase tracking-wider text-amber-950">
|
||||||
|
Final
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-400 bg-zinc-100 px-2 py-1 text-[11px] font-semibold text-zinc-700 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200">
|
||||||
|
{winnerLabel(winnerId, displayLeft, displayRight)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlayerPanel
|
||||||
|
side={displayRight}
|
||||||
|
isWinner={rightIsWinner}
|
||||||
|
isDimmed={noWinner ? true : !rightIsWinner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-300/80 pt-1.5 text-xs font-medium text-zinc-700 dark:border-zinc-700 dark:text-zinc-300">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
|
Entry {entryFee ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
|
Prize {prizeCc ?? "—"} CC
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
|
{createdAtLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/match-logs/${matchId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-md border border-zinc-400 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-800 shadow-sm transition hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Show logs
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Bracketed timestamp only `[…]`; space after `]` is not part of the gray stamp. */
|
||||||
|
const TIMESTAMP = /^(\[[^\]]+\])(\s*)([\s\S]*)$/;
|
||||||
|
|
||||||
|
const NORMAL = "text-zinc-50";
|
||||||
|
|
||||||
|
const SPAN_RULES: Array<{ re: RegExp; className: string }> = [
|
||||||
|
{ re: /Red goal scored/, className: "font-semibold !text-rose-400" },
|
||||||
|
{ re: /Blue goal scored/, className: "font-semibold !text-sky-400" },
|
||||||
|
{
|
||||||
|
re: /Dedicated match PATCH success: \d+/,
|
||||||
|
className: "!text-emerald-400",
|
||||||
|
},
|
||||||
|
{ re: /Game started On Server/, className: "!text-amber-400" },
|
||||||
|
{ re: /Starting server at port \d+/, className: "!text-amber-400" },
|
||||||
|
{ re: /launching puck at/, className: "!text-cyan-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLIENT_TEAM = /(Client \d+ set team to )(Red|Blue)/;
|
||||||
|
|
||||||
|
type MatchInfo =
|
||||||
|
| { index: number; len: number; kind: "span"; className: string }
|
||||||
|
| {
|
||||||
|
index: number;
|
||||||
|
len: number;
|
||||||
|
kind: "clientTeam";
|
||||||
|
prefix: string;
|
||||||
|
team: "Red" | "Blue";
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickEarlier(a: MatchInfo, b: MatchInfo): MatchInfo {
|
||||||
|
if (a.index < b.index) return a;
|
||||||
|
if (b.index < a.index) return b;
|
||||||
|
return a.len >= b.len ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextHighlight(sub: string): MatchInfo | null {
|
||||||
|
let best: MatchInfo | null = null;
|
||||||
|
|
||||||
|
const cm = CLIENT_TEAM.exec(sub);
|
||||||
|
if (cm) {
|
||||||
|
best = {
|
||||||
|
index: cm.index,
|
||||||
|
len: cm[0].length,
|
||||||
|
kind: "clientTeam",
|
||||||
|
prefix: cm[1] ?? "",
|
||||||
|
team: cm[2] === "Blue" ? "Blue" : "Red",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { re, className } of SPAN_RULES) {
|
||||||
|
const m = re.exec(sub);
|
||||||
|
if (!m || m.index === undefined) continue;
|
||||||
|
const cand: MatchInfo = {
|
||||||
|
index: m.index,
|
||||||
|
len: m[0].length,
|
||||||
|
kind: "span",
|
||||||
|
className,
|
||||||
|
};
|
||||||
|
best = best ? pickEarlier(best, cand) : cand;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightRest(rest: string, lineKey: number): ReactNode[] {
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let seg = 0;
|
||||||
|
|
||||||
|
while (i < rest.length) {
|
||||||
|
const sub = rest.slice(i);
|
||||||
|
const hit = findNextHighlight(sub);
|
||||||
|
if (!hit) {
|
||||||
|
out.push(
|
||||||
|
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||||
|
{rest.slice(i)}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abs = i + hit.index;
|
||||||
|
if (abs > i) {
|
||||||
|
out.push(
|
||||||
|
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||||
|
{rest.slice(i, abs)}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit.kind === "span") {
|
||||||
|
out.push(
|
||||||
|
<span key={`l${lineKey}-s${seg++}`} className={hit.className}>
|
||||||
|
{rest.slice(abs, abs + hit.len)}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const teamCls =
|
||||||
|
hit.team === "Red"
|
||||||
|
? "font-semibold !text-rose-400"
|
||||||
|
: "font-semibold !text-sky-400";
|
||||||
|
out.push(
|
||||||
|
<span key={`l${lineKey}-s${seg++}`} className={NORMAL}>
|
||||||
|
{hit.prefix}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
out.push(
|
||||||
|
<span key={`l${lineKey}-s${seg++}`} className={teamCls}>
|
||||||
|
{hit.team}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
i = abs + hit.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogLine(line: string, lineKey: number): ReactNode {
|
||||||
|
const m = line.match(TIMESTAMP);
|
||||||
|
if (!m) {
|
||||||
|
return <span className={NORMAL}>{line}</span>;
|
||||||
|
}
|
||||||
|
const bracketTs = m[1] ?? "";
|
||||||
|
const afterBracket = (m[2] ?? "") + (m[3] ?? "");
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className="!text-zinc-500">{bracketTs}</span>
|
||||||
|
{highlightRest(afterBracket, lineKey)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchLogColoredBody({ content }: { content: string }) {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${NORMAL} font-mono text-[13px] leading-relaxed break-words selection:bg-emerald-900/50 selection:text-emerald-100`}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap">
|
||||||
|
{formatLogLine(line, i)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||||
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
layout: "embedded" | "fullscreen";
|
||||||
|
source: MatchmakerLogSource;
|
||||||
|
processedHref: string;
|
||||||
|
rawHref: string;
|
||||||
|
content: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** If the user is within this many px of the bottom, treat as "following" the tail. */
|
||||||
|
const TAIL_FOLLOW_PX = 80;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
const REFRESH_BTN =
|
||||||
|
"shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
|
function segClass(active: boolean) {
|
||||||
|
return [
|
||||||
|
"rounded border px-2.5 py-1 font-mono text-xs transition",
|
||||||
|
active
|
||||||
|
? "border-emerald-500/60 bg-emerald-950/40 text-emerald-200"
|
||||||
|
: "border-zinc-600 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-white",
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogApi(source: MatchmakerLogSource): Promise<
|
||||||
|
| { ok: true; content: string }
|
||||||
|
| { ok: false; message: string }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (source === "raw") params.set("mklog", "raw");
|
||||||
|
params.set("_t", String(Date.now()));
|
||||||
|
const res = await fetch(`/api/matchmaker-logs?${params.toString()}`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
const rawText = await res.text();
|
||||||
|
let data: { content?: string; error?: string };
|
||||||
|
try {
|
||||||
|
data = rawText ? (JSON.parse(rawText) as typeof data) : {};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
"Server returned non-JSON (lost session?). Refresh and sign in again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: data.error ?? `Request failed (${res.status})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, content: data.content ?? "" };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: e instanceof Error ? e.message : "Network error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchmakerLogTerminal({
|
||||||
|
layout,
|
||||||
|
source,
|
||||||
|
processedHref,
|
||||||
|
rawHref,
|
||||||
|
content,
|
||||||
|
errorMessage,
|
||||||
|
}: Props) {
|
||||||
|
const filename =
|
||||||
|
source === "processed" ? "history.log" : "matchmaker.log";
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tailFollowRef = useRef(true);
|
||||||
|
/** Avoid overlapping auto + manual fetches (helps dev Strict Mode / double clicks). */
|
||||||
|
const fetchLockRef = useRef(false);
|
||||||
|
|
||||||
|
const [displayContent, setDisplayContent] = useState(content);
|
||||||
|
const [displayError, setDisplayError] = useState(errorMessage);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
/** Bumped on every successful reload so identical file text still updates the UI (React skips re-render when setState equals previous string). */
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [lastFetchedAtMs, setLastFetchedAtMs] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const runLoad = useCallback(async (manual: boolean) => {
|
||||||
|
if (
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.hidden &&
|
||||||
|
!manual
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fetchLockRef.current) return;
|
||||||
|
|
||||||
|
fetchLockRef.current = true;
|
||||||
|
if (manual) {
|
||||||
|
tailFollowRef.current = true;
|
||||||
|
setRefreshing(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fetchLogApi(source);
|
||||||
|
if (!result.ok) {
|
||||||
|
setDisplayError(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDisplayError(null);
|
||||||
|
setDisplayContent(result.content);
|
||||||
|
if (manual) {
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
setLastFetchedAtMs(Date.now());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchLockRef.current = false;
|
||||||
|
if (manual) setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await runLoad(true);
|
||||||
|
}, [runLoad]);
|
||||||
|
|
||||||
|
const updateTailFollowFromScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
tailFollowRef.current = gap <= TAIL_FOLLOW_PX;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayContent(content);
|
||||||
|
setDisplayError(errorMessage);
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
}, [content, errorMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tailFollowRef.current = true;
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (cancelled) return;
|
||||||
|
await runLoad(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(tick);
|
||||||
|
const intervalId = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (!document.hidden && !cancelled) void tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [source, runLoad]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el || !tailFollowRef.current) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}, [displayContent, displayError, reloadKey]);
|
||||||
|
|
||||||
|
const frameClass =
|
||||||
|
layout === "fullscreen"
|
||||||
|
? "flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg"
|
||||||
|
: "flex h-[calc(100dvh-12rem)] max-h-[calc(100dvh-12rem)] min-h-[200px] shrink-0 flex-col overflow-hidden rounded-lg border border-zinc-700/80 bg-[#0d1117] text-zinc-100 shadow-lg";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={frameClass}>
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||||
|
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||||
|
</div>
|
||||||
|
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||||
|
<span className="text-emerald-400/90">matchmaker</span>
|
||||||
|
<span className="text-zinc-500"> — </span>
|
||||||
|
<span className="text-zinc-100">{filename}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-wide text-zinc-500">
|
||||||
|
Log
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={processedHref}
|
||||||
|
className={segClass(source === "processed")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Processed
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={rawHref}
|
||||||
|
className={segClass(source === "raw")}
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Raw
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={REFRESH_BTN}
|
||||||
|
disabled={refreshing}
|
||||||
|
aria-busy={refreshing}
|
||||||
|
aria-label="Refresh log from disk"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
>
|
||||||
|
{refreshing ? "…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
{lastFetchedAtMs != null ? (
|
||||||
|
<span className="font-mono text-[10px] text-zinc-500">
|
||||||
|
· manual{" "}
|
||||||
|
{new Date(lastFetchedAtMs).toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-mono text-[10px] text-zinc-500">
|
||||||
|
· auto {POLL_INTERVAL_MS / 1000}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={updateTailFollowFromScroll}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4"
|
||||||
|
>
|
||||||
|
{displayError ? (
|
||||||
|
<p className="font-mono text-sm text-red-400">
|
||||||
|
<span className="text-red-500/80">error:</span> {displayError}
|
||||||
|
</p>
|
||||||
|
) : displayContent === "" ? (
|
||||||
|
<p className="font-mono text-sm text-zinc-600">(empty file)</p>
|
||||||
|
) : (
|
||||||
|
<MatchLogColoredBody
|
||||||
|
key={reloadKey}
|
||||||
|
content={displayContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="shrink-0 border-t border-zinc-800 bg-[#161b22] px-3 py-1.5">
|
||||||
|
<p className="font-mono text-[11px] text-zinc-500">
|
||||||
|
<span className="text-emerald-600/80">⏎</span> UTF-8
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextResponse } from "next/server";
|
||||||
|
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
||||||
|
function isHttpsRequest(request: Request): boolean {
|
||||||
|
try {
|
||||||
|
if (new URL(request.url).protocol === "https:") return true;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const xf = request.headers.get("x-forwarded-proto");
|
||||||
|
if (xf === "https") return true;
|
||||||
|
if (xf === "http") return false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCookieSetOptions(request: Request) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isHttpsRequest(request),
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
maxAge: ADMIN_SESSION_MAX_AGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCookieClearOptions(request: Request) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isHttpsRequest(request),
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach session cookie to a response (works reliably with redirects). */
|
||||||
|
export function applySessionCookie(res: NextResponse, request: Request) {
|
||||||
|
res.cookies.set(
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
"1",
|
||||||
|
getSessionCookieSetOptions(request),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** HttpOnly cookie name for admin session (set after successful login). */
|
||||||
|
export const ADMIN_SESSION_COOKIE = "kickkings_admin_session";
|
||||||
|
|
||||||
|
/** Session cookie max-age in seconds (30 days). */
|
||||||
|
export const ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* RC ↔ coins mapping for `entry_fee` storage.
|
||||||
|
*
|
||||||
|
* RC = floor(coins / 4) + (coins mod 4) / 10
|
||||||
|
* coins = floor(RC) * 4 + first decimal digit of RC (0–3)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseStoredCoins(raw: string | null): number {
|
||||||
|
if (raw == null || String(raw).trim() === "") return 0;
|
||||||
|
const n = Number(String(raw).trim());
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coinsToRc(coins: number): number {
|
||||||
|
const c = Math.max(0, Math.floor(coins));
|
||||||
|
return Math.floor(c / 4) + (c % 4) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-decimal RC only; tenths digit must be 0–3. */
|
||||||
|
export function rcToCoins(rc: number): number | null {
|
||||||
|
if (!Number.isFinite(rc) || rc < 0) return null;
|
||||||
|
const whole = Math.floor(rc + 1e-9);
|
||||||
|
const frac = rc - whole;
|
||||||
|
const scaled = frac * 10;
|
||||||
|
const tenths = Math.round(scaled);
|
||||||
|
if (Math.abs(scaled - tenths) > 0.01) return null;
|
||||||
|
if (tenths < 0 || tenths > 3) return null;
|
||||||
|
return whole * 4 + tenths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRcLabelFromCoins(coins: number): string {
|
||||||
|
return `${coinsToRc(Math.max(0, Math.floor(coins))).toFixed(1)} RC`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
|
|
||||||
|
export type AdminDashboardTab =
|
||||||
|
| "dashboard"
|
||||||
|
| "players"
|
||||||
|
| "matches"
|
||||||
|
| "matchmaker";
|
||||||
|
|
||||||
|
export type DashboardUrlQuery = {
|
||||||
|
tab: AdminDashboardTab;
|
||||||
|
highlightId: string | null;
|
||||||
|
participantRaw: string | null;
|
||||||
|
editId?: number | null;
|
||||||
|
saveError?: boolean;
|
||||||
|
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||||
|
matchmakerSource?: MatchmakerLogSource | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
|
||||||
|
export function buildDashboardHref(q: DashboardUrlQuery): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (q.tab === "matches") p.set("tab", "matches");
|
||||||
|
else if (q.tab === "players") p.set("tab", "players");
|
||||||
|
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
||||||
|
if (q.highlightId) p.set("highlight", q.highlightId);
|
||||||
|
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||||
|
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
||||||
|
p.set("mklog", "raw");
|
||||||
|
}
|
||||||
|
if (q.editId != null && Number.isFinite(q.editId)) {
|
||||||
|
p.set("edit", String(q.editId));
|
||||||
|
}
|
||||||
|
if (q.saveError) p.set("saveError", "1");
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `/?${s}` : "/";
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export type DashboardStatsSnapshot = {
|
||||||
|
activePlayersToday: number;
|
||||||
|
activePlayersLastWeek: number;
|
||||||
|
activePlayersLastMonth: number;
|
||||||
|
matchesToday: number;
|
||||||
|
matchesLastWeek: number;
|
||||||
|
matchesLastMonth: number;
|
||||||
|
totalUsers: number;
|
||||||
|
totalMatches: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeaderboardRow = {
|
||||||
|
userId: number;
|
||||||
|
username: string | null;
|
||||||
|
totalPrizeCc: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardStatsBundle = {
|
||||||
|
stats: DashboardStatsSnapshot | null;
|
||||||
|
leaderboard: LeaderboardRow[];
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start of the current calendar day in UTC (matches admin timestamps shown as UTC). */
|
||||||
|
function utcStartOfTodayIso(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return start.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoRollingDaysAgo(days: number): string {
|
||||||
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCount(
|
||||||
|
res: { count: number | null; error: { message: string } | null },
|
||||||
|
label: string,
|
||||||
|
): { value: number | null; err: string | null } {
|
||||||
|
if (res.error) {
|
||||||
|
return { value: null, err: `${label}: ${res.error.message}` };
|
||||||
|
}
|
||||||
|
return { value: res.count ?? 0, err: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTopWinnersByPrizeCc(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
): Promise<{ rows: LeaderboardRow[]; error: string | null }> {
|
||||||
|
const winnings = new Map<number, number>();
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("winner_id, prize_cc")
|
||||||
|
.not("winner_id", "is", null)
|
||||||
|
.range(offset, offset + pageSize - 1);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { rows: [], error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = data ?? [];
|
||||||
|
for (const row of batch) {
|
||||||
|
const wid = row.winner_id as number;
|
||||||
|
const raw = row.prize_cc;
|
||||||
|
const prize =
|
||||||
|
typeof raw === "number"
|
||||||
|
? raw
|
||||||
|
: raw != null
|
||||||
|
? Number(raw)
|
||||||
|
: 0;
|
||||||
|
const add = Number.isFinite(prize) ? prize : 0;
|
||||||
|
winnings.set(wid, (winnings.get(wid) ?? 0) + add);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topPairs = [...winnings.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (topPairs.length === 0) {
|
||||||
|
return { rows: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = topPairs.map(([id]) => id);
|
||||||
|
const { data: userRows, error: userErr } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, username")
|
||||||
|
.in("id", ids);
|
||||||
|
|
||||||
|
if (userErr) {
|
||||||
|
return { rows: [], error: userErr.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameById = new Map<number, string | null>();
|
||||||
|
for (const u of userRows ?? []) {
|
||||||
|
nameById.set(u.id as number, (u.username as string | null) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: LeaderboardRow[] = topPairs.map(([userId, totalPrizeCc]) => ({
|
||||||
|
userId,
|
||||||
|
username: nameById.get(userId) ?? null,
|
||||||
|
totalPrizeCc,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { rows, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregates counts and leaderboard for the admin overview tab. Server-only. */
|
||||||
|
export async function loadDashboardStatsBundle(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
): Promise<DashboardStatsBundle> {
|
||||||
|
const startToday = utcStartOfTodayIso();
|
||||||
|
const weekAgo = isoRollingDaysAgo(7);
|
||||||
|
const monthAgo = isoRollingDaysAgo(30);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalUsersRes,
|
||||||
|
totalMatchesRes,
|
||||||
|
activeTodayRes,
|
||||||
|
activeWeekRes,
|
||||||
|
activeMonthRes,
|
||||||
|
matchesTodayRes,
|
||||||
|
matchesWeekRes,
|
||||||
|
matchesMonthRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from("users").select("id", { count: "exact", head: true }),
|
||||||
|
supabase.from("matches").select("id", { count: "exact", head: true }),
|
||||||
|
supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("last_logged_at", startToday),
|
||||||
|
supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("last_logged_at", weekAgo),
|
||||||
|
supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("last_logged_at", monthAgo),
|
||||||
|
supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("created_at", startToday),
|
||||||
|
supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("created_at", weekAgo),
|
||||||
|
supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("created_at", monthAgo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
pickCount(totalUsersRes, "total users"),
|
||||||
|
pickCount(totalMatchesRes, "total matches"),
|
||||||
|
pickCount(activeTodayRes, "active players today"),
|
||||||
|
pickCount(activeWeekRes, "active players (7d)"),
|
||||||
|
pickCount(activeMonthRes, "active players (30d)"),
|
||||||
|
pickCount(matchesTodayRes, "matches today"),
|
||||||
|
pickCount(matchesWeekRes, "matches (7d)"),
|
||||||
|
pickCount(matchesMonthRes, "matches (30d)"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const errs = parts.map((p) => p.err).filter(Boolean) as string[];
|
||||||
|
const values = parts.map((p) => p.value);
|
||||||
|
|
||||||
|
const allOk = values.every((v) => v !== null);
|
||||||
|
const stats: DashboardStatsSnapshot | null = allOk
|
||||||
|
? {
|
||||||
|
totalUsers: values[0]!,
|
||||||
|
totalMatches: values[1]!,
|
||||||
|
activePlayersToday: values[2]!,
|
||||||
|
activePlayersLastWeek: values[3]!,
|
||||||
|
activePlayersLastMonth: values[4]!,
|
||||||
|
matchesToday: values[5]!,
|
||||||
|
matchesLastWeek: values[6]!,
|
||||||
|
matchesLastMonth: values[7]!,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { rows: leaderboard, error: lbErr } =
|
||||||
|
await fetchTopWinnersByPrizeCc(supabase);
|
||||||
|
|
||||||
|
const errorMessages = [...errs, ...(lbErr ? [`leaderboard: ${lbErr}`] : [])];
|
||||||
|
const error = errorMessages.length > 0 ? errorMessages.join(" · ") : null;
|
||||||
|
|
||||||
|
return { stats, leaderboard, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function parseMatchIdParam(raw: string): number | null {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n < 1) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadMatchLogResult =
|
||||||
|
| { ok: true; content: string }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
status: 400 | 401 | 404 | 413 | 500 | 503;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readMatchLogFile(
|
||||||
|
matchId: number,
|
||||||
|
): Promise<ReadMatchLogResult> {
|
||||||
|
const dirRaw = process.env.MATCH_LOGS_DIR?.trim();
|
||||||
|
if (!dirRaw) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
message:
|
||||||
|
"Match logs directory is not configured (set MATCH_LOGS_DIR on the server).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.resolve(dirRaw);
|
||||||
|
const filePath = path.resolve(base, `${matchId}.txt`);
|
||||||
|
if (!filePath.startsWith(base + path.sep)) {
|
||||||
|
return { ok: false, status: 500, message: "Invalid log path." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let st: Awaited<ReturnType<typeof fs.stat>>;
|
||||||
|
try {
|
||||||
|
st = await fs.stat(filePath);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code =
|
||||||
|
e && typeof e === "object" && "code" in e
|
||||||
|
? String((e as { code: unknown }).code)
|
||||||
|
: "";
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
message: "No log file for this match.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 500, message: "Could not access log file." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!st.isFile()) {
|
||||||
|
return { ok: false, status: 404, message: "Log path is not a file." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.size > MAX_LOG_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 413,
|
||||||
|
message: `Log file is too large (max ${MAX_LOG_BYTES} bytes).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = await fs.readFile(filePath);
|
||||||
|
return { ok: true, content: buf.toString("utf8") };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 500, message: "Could not read log file." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type MatchmakerLogSource = "processed" | "raw";
|
||||||
|
|
||||||
|
export function parseMatchmakerLogParam(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): MatchmakerLogSource {
|
||||||
|
if (raw === "raw") return "raw";
|
||||||
|
return "processed";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
const FILENAMES = {
|
||||||
|
processed: "history.log",
|
||||||
|
raw: "matchmaker.log",
|
||||||
|
} as const satisfies Record<MatchmakerLogSource, string>;
|
||||||
|
|
||||||
|
export type ReadMatchmakerLogResult =
|
||||||
|
| { ok: true; content: string; filename: string; source: MatchmakerLogSource }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
status: 404 | 413 | 500 | 503;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readMatchmakerLogFile(
|
||||||
|
source: MatchmakerLogSource,
|
||||||
|
): Promise<ReadMatchmakerLogResult> {
|
||||||
|
const dirRaw = process.env.MATCHMAKER_LOGS_DIR?.trim();
|
||||||
|
if (!dirRaw) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
message:
|
||||||
|
"Matchmaker logs directory is not configured (set MATCHMAKER_LOGS_DIR on the server).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.resolve(dirRaw);
|
||||||
|
const name = FILENAMES[source];
|
||||||
|
const filePath = path.resolve(base, name);
|
||||||
|
if (!filePath.startsWith(base + path.sep)) {
|
||||||
|
return { ok: false, status: 500, message: "Invalid log path." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let st: Awaited<ReturnType<typeof fs.stat>>;
|
||||||
|
try {
|
||||||
|
st = await fs.stat(filePath);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const code =
|
||||||
|
e && typeof e === "object" && "code" in e
|
||||||
|
? String((e as { code: unknown }).code)
|
||||||
|
: "";
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
message: `No matchmaker log file (${name}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 500, message: "Could not access log file." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!st.isFile()) {
|
||||||
|
return { ok: false, status: 404, message: "Log path is not a file." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.size > MAX_LOG_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 413,
|
||||||
|
message: `Log file is too large (max ${MAX_LOG_BYTES} bytes).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = await fs.readFile(filePath);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
content: buf.toString("utf8"),
|
||||||
|
filename: name,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 500, message: "Could not read log file." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
function stripTrailingSlashes(s: string): string {
|
||||||
|
return s.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip accidental CRLF / extra lines (e.g. Windows .env) — `\r` mid-URL becomes `...comlogin`. */
|
||||||
|
function firstUrlLine(s: string): string {
|
||||||
|
return s.split("\r")[0]?.split("\n")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `playpoolstudios.com` + `login` (no `/`) often becomes host `...comlogin` in env or proxy headers.
|
||||||
|
*/
|
||||||
|
function normalizeMergedComLoginHost(hostWithOptionalPort: string): string {
|
||||||
|
const raw = firstUrlLine(hostWithOptionalPort).trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
|
||||||
|
let hostPart = raw;
|
||||||
|
let portPart = "";
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
const end = raw.indexOf("]:");
|
||||||
|
if (end !== -1) {
|
||||||
|
hostPart = raw.slice(0, end + 1);
|
||||||
|
portPart = raw.slice(end + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const idx = raw.lastIndexOf(":");
|
||||||
|
if (idx > 0 && /^\d+$/.test(raw.slice(idx + 1))) {
|
||||||
|
hostPart = raw.slice(0, idx);
|
||||||
|
portPart = raw.slice(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = hostPart.replace(/\.comlogin$/i, ".com");
|
||||||
|
return fixed + portPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseForwardedHeader(value: string | null): {
|
||||||
|
host?: string;
|
||||||
|
proto?: string;
|
||||||
|
} {
|
||||||
|
if (!value) return {};
|
||||||
|
const segment = value.split(",")[0]?.trim() ?? "";
|
||||||
|
const out: { host?: string; proto?: string } = {};
|
||||||
|
for (const part of segment.split(";")) {
|
||||||
|
const eq = part.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = part.slice(0, eq).trim().toLowerCase();
|
||||||
|
let val = part.slice(eq + 1).trim();
|
||||||
|
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||||
|
if (key === "host") out.host = firstUrlLine(val);
|
||||||
|
if (key === "proto") out.proto = firstUrlLine(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public origin for absolute redirects when the app sits behind a reverse proxy.
|
||||||
|
* Override with APP_ORIGIN (e.g. https://kickkings.playpoolstudios.com) if headers are absent.
|
||||||
|
*/
|
||||||
|
export function getRequestOrigin(request: Request): string {
|
||||||
|
const explicit = firstUrlLine(process.env.APP_ORIGIN ?? "");
|
||||||
|
if (explicit) {
|
||||||
|
const normalized = /^[a-z][a-z0-9+.-]*:/i.test(explicit)
|
||||||
|
? explicit
|
||||||
|
: `https://${explicit}`;
|
||||||
|
try {
|
||||||
|
const u = new URL(stripTrailingSlashes(normalized));
|
||||||
|
u.hostname = normalizeMergedComLoginHost(u.hostname);
|
||||||
|
return u.origin;
|
||||||
|
} catch {
|
||||||
|
return stripTrailingSlashes(explicit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const fwd = parseForwardedHeader(request.headers.get("forwarded"));
|
||||||
|
|
||||||
|
const xfHost = request.headers.get("x-forwarded-host");
|
||||||
|
const hostFromHeaders = fwd.host ?? xfHost?.split(",")[0]?.trim() ?? "";
|
||||||
|
const hostRaw = normalizeMergedComLoginHost(
|
||||||
|
firstUrlLine(hostFromHeaders) || url.host,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xfProto = request.headers.get("x-forwarded-proto");
|
||||||
|
const protoRaw = firstUrlLine(
|
||||||
|
fwd.proto ?? xfProto?.split(",")[0]?.trim() ?? (url.protocol === "https:" ? "https" : "http"),
|
||||||
|
);
|
||||||
|
const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(`${proto}://${hostRaw}`).origin;
|
||||||
|
} catch {
|
||||||
|
url.hostname = normalizeMergedComLoginHost(url.hostname);
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicRequestUrl(request: Request, pathname: string): URL {
|
||||||
|
const origin = getRequestOrigin(request);
|
||||||
|
const base = new URL(origin);
|
||||||
|
const path = pathname.trim();
|
||||||
|
const p = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return new URL(p, base);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-only Supabase client using the service role key so the admin UI can
|
||||||
|
* read all rows regardless of RLS. Never import this from client components.
|
||||||
|
*/
|
||||||
|
export function createAdminSupabase(): SupabaseClient | null {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!url || !key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createClient(url, key, {
|
||||||
|
auth: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
|
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||||
|
|
||||||
|
function hasValidSession(request: NextRequest): boolean {
|
||||||
|
return request.cookies.get(ADMIN_SESSION_COOKIE)?.value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const isLogin = pathname === "/login";
|
||||||
|
const isAuthApi = pathname.startsWith("/api/auth/");
|
||||||
|
|
||||||
|
if (isAuthApi) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let route handlers return JSON (401, etc.). A redirect to /login breaks `fetch` + `res.json()` in the admin UI.
|
||||||
|
if (!hasValidSession(request) && pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLogin) {
|
||||||
|
if (hasValidSession(request)) {
|
||||||
|
return NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidSession(request)) {
|
||||||
|
return NextResponse.redirect(publicRequestUrl(request, "/login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** Mirrors `public.users` (see schemas/users.md). */
|
||||||
|
export type DbUser = {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
username: string | null;
|
||||||
|
password: string | null;
|
||||||
|
cc: number | null;
|
||||||
|
rc: number | null;
|
||||||
|
last_logged_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors `public.matches` (see schemas/matches.md). */
|
||||||
|
export type DbMatch = {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
/** BIGINT may deserialize as string over JSON. */
|
||||||
|
user_red: number | string | null;
|
||||||
|
user_blue: number | string | null;
|
||||||
|
entry_fee: number | null;
|
||||||
|
prize_cc: number | null;
|
||||||
|
red_joined_at: string | null;
|
||||||
|
blue_joined_at: string | null;
|
||||||
|
status: number | null;
|
||||||
|
/** BIGINT may deserialize as string over JSON. */
|
||||||
|
winner_id: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mirrors `public.settings` (key/value config rows). */
|
||||||
|
export type DbSetting = {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user